@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
|
@@ -5,8 +5,15 @@ import { Options } from "minisearch";
|
|
|
5
5
|
//#region src/react/search-dialog.d.ts
|
|
6
6
|
interface SearchDialogProps {
|
|
7
7
|
/**
|
|
8
|
-
* URL of the serialised index, e.g. `/search-index.json`.
|
|
9
|
-
*
|
|
8
|
+
* URL of the serialised index, e.g. `/docs/search-index.json`.
|
|
9
|
+
*
|
|
10
|
+
* `docs.searchIndexUrl` is the value to pass: it is derived from the route's
|
|
11
|
+
* `basePath`, so it is right when the docs are mounted anywhere but the root.
|
|
12
|
+
* `docs.Layout` passes it for you.
|
|
13
|
+
*
|
|
14
|
+
* (This used to say "whatever `writeSearchIndex` wrote". That function was
|
|
15
|
+
* deleted in 0.3.0, along with the build script it needed — the index is a
|
|
16
|
+
* `force-static` route handler now.)
|
|
10
17
|
*/
|
|
11
18
|
indexUrl: string;
|
|
12
19
|
/**
|
|
@@ -26,10 +33,90 @@ interface SearchDialogProps {
|
|
|
26
33
|
placeholder?: string | undefined;
|
|
27
34
|
/** Accessible name for the dialog. Defaults to `'Search documentation'`. */
|
|
28
35
|
dialogLabel?: string | undefined;
|
|
29
|
-
/**
|
|
30
|
-
|
|
36
|
+
/**
|
|
37
|
+
* How many results to render at a time. Defaults to 20.
|
|
38
|
+
*
|
|
39
|
+
* ⚠️ NOT A CAP. Every match is reachable — the list renders this many, then
|
|
40
|
+
* another `pageSize` each time the reader scrolls near the end, so the DOM
|
|
41
|
+
* stays bounded without anything being withheld.
|
|
42
|
+
*
|
|
43
|
+
* This was `maxResults`, and it was a hard ceiling of 8. On a *six-page*
|
|
44
|
+
* site "docs" matches 18, so ten results simply could not be reached, and
|
|
45
|
+
* the live region announced "8 results" — not a smaller truth but a false
|
|
46
|
+
* one. The ceiling was justified by a claim nobody had measured, and the
|
|
47
|
+
* measurement did not support it: on a 300-page corpus (2,100 records) a
|
|
48
|
+
* query costs 1.3–3.0 ms and rendering *every* row costs 40 ms, 128 ms at
|
|
49
|
+
* 4x CPU throttle. Paging exists to keep that worst case from ever being
|
|
50
|
+
* reached, not because the search cannot find things.
|
|
51
|
+
*/
|
|
52
|
+
pageSize?: number | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* Shortest query that runs. Defaults to 2.
|
|
55
|
+
*
|
|
56
|
+
* A single character is not a query — measured on this package's own docs,
|
|
57
|
+
* "a" matches 100% of the corpus, "i" 97%, "s" 93%. Answering those wastes a
|
|
58
|
+
* render and, worse, teaches a reader mid-word that search returns noise.
|
|
59
|
+
*
|
|
60
|
+
* ⚠️ TWO, NOT THREE, AND THE DIFFERENCE MATTERS ON A DOCS SITE. Three would
|
|
61
|
+
* refuse `ts`, `js`, `id`, `h1`, `px` — every one a real query here, and each
|
|
62
|
+
* one selective: 10%, 17%, 14%, 3%, 0%. The noise is at one character, so
|
|
63
|
+
* that is where the floor goes.
|
|
64
|
+
*
|
|
65
|
+
* A word like `is` still matches 83%; that is a stopword problem rather than
|
|
66
|
+
* a length one, and `miniSearchOptions.processTerm` is the tool for it.
|
|
67
|
+
*/
|
|
68
|
+
minQueryLength?: number | undefined;
|
|
31
69
|
/** Input debounce in milliseconds. Defaults to 120. */
|
|
32
70
|
debounceMs?: number | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* Shown before anything is typed. Defaults to
|
|
73
|
+
* `'Start typing to search the documentation.'`
|
|
74
|
+
*/
|
|
75
|
+
hintLabel?: string | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* Shown while a query is below {@link SearchDialogProps.minQueryLength}.
|
|
78
|
+
* Defaults to `'Keep typing — {min} characters or more.'`
|
|
79
|
+
*
|
|
80
|
+
* `{min}` is replaced with that number. Said rather than silently done: a
|
|
81
|
+
* dialog that answers nothing and explains nothing reads as broken, and this
|
|
82
|
+
* is the state every reader passes through on the way to their real query.
|
|
83
|
+
*/
|
|
84
|
+
shortQueryLabel?: string | undefined;
|
|
85
|
+
/** Shown while the index is being fetched. Defaults to `'Loading the search index…'`. */
|
|
86
|
+
loadingLabel?: string | undefined;
|
|
87
|
+
/**
|
|
88
|
+
* Shown when the index cannot be loaded. Defaults to
|
|
89
|
+
* `'Search is unavailable right now. Try reloading the page.'`
|
|
90
|
+
*/
|
|
91
|
+
errorLabel?: string | undefined;
|
|
92
|
+
/**
|
|
93
|
+
* Shown when a query matches nothing. Defaults to `'No results for “{query}”.'`
|
|
94
|
+
*
|
|
95
|
+
* `{query}` is replaced with what the reader typed.
|
|
96
|
+
*/
|
|
97
|
+
emptyLabel?: string | undefined;
|
|
98
|
+
/**
|
|
99
|
+
* The live region's announcement, by plural category. `{count}` is the total.
|
|
100
|
+
*
|
|
101
|
+
* Defaults to `{ one: '{count} result', other: '{count} results' }`.
|
|
102
|
+
*
|
|
103
|
+
* ⚠️ KEYED BY CATEGORY RATHER THAN BEING TWO STRINGS, BECAUSE MOST LANGUAGES
|
|
104
|
+
* ARE NOT ENGLISH. Polish takes four forms and Arabic six;
|
|
105
|
+
* `Intl.PluralRules(locale).select(count)` picks, and an unlisted category
|
|
106
|
+
* falls back to `other`. Two props called "singular" and "plural" would have
|
|
107
|
+
* made this package announce a wrong number of results, correctly, in most of
|
|
108
|
+
* the world.
|
|
109
|
+
*/
|
|
110
|
+
resultCountLabels?: Partial<Record<Intl.LDMLPluralRule, string>> | undefined;
|
|
111
|
+
/**
|
|
112
|
+
* Language tag for the plural rules above. Defaults to the document's own
|
|
113
|
+
* `<html lang>`, then to `'en'`.
|
|
114
|
+
*
|
|
115
|
+
* Read at announcement time rather than at render, so it costs nothing on a
|
|
116
|
+
* site that never changes it and needs no prop on a site that sets `lang`
|
|
117
|
+
* correctly — which is every site that should be setting these labels at all.
|
|
118
|
+
*/
|
|
119
|
+
locale?: string | undefined;
|
|
33
120
|
/** Extra class names for the trigger button, e.g. a navbar's own layout. */
|
|
34
121
|
className?: string | undefined;
|
|
35
122
|
/**
|
|
@@ -64,6 +151,6 @@ interface SearchDialogProps {
|
|
|
64
151
|
* portalled to `document.body`, so a navbar's stacking context cannot trap
|
|
65
152
|
* it behind the page.
|
|
66
153
|
*/
|
|
67
|
-
declare function SearchDialog({ indexUrl, navigate, Link, triggerLabel, placeholder, dialogLabel,
|
|
154
|
+
declare function SearchDialog({ indexUrl, navigate, Link, triggerLabel, placeholder, dialogLabel, pageSize, minQueryLength, debounceMs, className, miniSearchOptions, hintLabel, shortQueryLabel, loadingLabel, errorLabel, emptyLabel, resultCountLabels, locale }: SearchDialogProps): ReactNode;
|
|
68
155
|
//#endregion
|
|
69
156
|
export { SearchDialog, SearchDialogProps };
|
|
@@ -26,10 +26,31 @@ const FOCUSABLE_SELECTOR = [
|
|
|
26
26
|
* portalled to `document.body`, so a navbar's stacking context cannot trap
|
|
27
27
|
* it behind the page.
|
|
28
28
|
*/
|
|
29
|
-
function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", placeholder = "Search documentation", dialogLabel = "Search documentation",
|
|
29
|
+
function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", placeholder = "Search documentation", dialogLabel = "Search documentation", pageSize = 20, minQueryLength = 2, debounceMs = 120, className, miniSearchOptions, hintLabel, shortQueryLabel, loadingLabel, errorLabel, emptyLabel, resultCountLabels, locale }) {
|
|
30
30
|
const [isOpen, setIsOpen] = useState(false);
|
|
31
31
|
const [query, setQuery] = useState("");
|
|
32
32
|
const [hits, setHits] = useState([]);
|
|
33
|
+
/**
|
|
34
|
+
* How many of `hits` are rendered.
|
|
35
|
+
*
|
|
36
|
+
* `hits` holds every match; this is the window. It grows by `pageSize` when
|
|
37
|
+
* the reader scrolls near the end, and whenever the keyboard walks past it —
|
|
38
|
+
* so an option always exists for `aria-activedescendant` to point at.
|
|
39
|
+
*/
|
|
40
|
+
const [visibleCount, setVisibleCount] = useState(pageSize);
|
|
41
|
+
/**
|
|
42
|
+
* Whether the active option moved because of a key, rather than a pointer.
|
|
43
|
+
*
|
|
44
|
+
* ⚠️ THE SCROLL-INTO-VIEW BELOW MUST NOT RUN FOR A HOVER. Pointing at a row
|
|
45
|
+
* that is half-clipped by the top or bottom edge set the active index, which
|
|
46
|
+
* scrolled that row flush — moving the whole list under the cursor, which
|
|
47
|
+
* then landed on a different row. Measured: hovering the visible sliver of a
|
|
48
|
+
* clipped row jumped the list 28px.
|
|
49
|
+
*
|
|
50
|
+
* A ref rather than state: it records how the *last* change happened and must
|
|
51
|
+
* not itself cause a render.
|
|
52
|
+
*/
|
|
53
|
+
const movedByKeyboard = useRef(false);
|
|
33
54
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
34
55
|
const [status, setStatus] = useState("idle");
|
|
35
56
|
const [shortcutHint, setShortcutHint] = useState("");
|
|
@@ -135,9 +156,10 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
135
156
|
}, [isOpen]);
|
|
136
157
|
useEffect(() => {
|
|
137
158
|
const trimmed = query.trim();
|
|
138
|
-
if (trimmed
|
|
159
|
+
if (trimmed.length < minQueryLength) {
|
|
139
160
|
setHits([]);
|
|
140
161
|
setActiveIndex(0);
|
|
162
|
+
setVisibleCount(pageSize);
|
|
141
163
|
return;
|
|
142
164
|
}
|
|
143
165
|
let isCancelled = false;
|
|
@@ -145,8 +167,9 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
145
167
|
ensureIndex().then((index) => {
|
|
146
168
|
if (isCancelled) return;
|
|
147
169
|
setStatus("ready");
|
|
148
|
-
setHits(index.search(trimmed).
|
|
170
|
+
setHits(index.search(trimmed).map(toSearchHit).filter(isSearchHit));
|
|
149
171
|
setActiveIndex(0);
|
|
172
|
+
setVisibleCount(pageSize);
|
|
150
173
|
}, () => {
|
|
151
174
|
if (!isCancelled) setStatus("error");
|
|
152
175
|
});
|
|
@@ -158,11 +181,55 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
158
181
|
}, [
|
|
159
182
|
query,
|
|
160
183
|
ensureIndex,
|
|
161
|
-
|
|
162
|
-
debounceMs
|
|
184
|
+
pageSize,
|
|
185
|
+
debounceMs,
|
|
186
|
+
minQueryLength
|
|
163
187
|
]);
|
|
188
|
+
/**
|
|
189
|
+
* Reveal another page when the reader nears the end of the list.
|
|
190
|
+
*
|
|
191
|
+
* A scroll handler rather than an `IntersectionObserver` on a sentinel: the
|
|
192
|
+
* scrollport is one element this component already holds a ref to, the test
|
|
193
|
+
* is one subtraction, and an observer would cost bytes on the largest client
|
|
194
|
+
* entry this package ships for no behaviour the reader can tell apart.
|
|
195
|
+
*
|
|
196
|
+
* `passive`, because this never calls `preventDefault` and a non-passive
|
|
197
|
+
* scroll listener blocks the compositor on every wheel event.
|
|
198
|
+
*
|
|
199
|
+
* No `isOpen` dependency, though the list does not exist while the dialog is
|
|
200
|
+
* closed: the effect returns early on a null ref, and `hits.length` going
|
|
201
|
+
* from 0 to N re-runs it — which happens after the list has mounted, because
|
|
202
|
+
* closing resets the query. The listener attaches exactly when there is
|
|
203
|
+
* something to scroll.
|
|
204
|
+
*/
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
const list = listRef.current;
|
|
207
|
+
if (list === null || visibleCount >= hits.length) return;
|
|
208
|
+
const onScroll = () => {
|
|
209
|
+
if (list.scrollHeight - list.scrollTop - list.clientHeight < list.clientHeight) setVisibleCount((count) => Math.min(count + pageSize, hits.length));
|
|
210
|
+
};
|
|
211
|
+
list.addEventListener("scroll", onScroll, { passive: true });
|
|
212
|
+
return () => list.removeEventListener("scroll", onScroll);
|
|
213
|
+
}, [
|
|
214
|
+
visibleCount,
|
|
215
|
+
hits.length,
|
|
216
|
+
pageSize
|
|
217
|
+
]);
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
if (activeIndex >= visibleCount) setVisibleCount(Math.min(activeIndex + 1, hits.length));
|
|
220
|
+
}, [
|
|
221
|
+
activeIndex,
|
|
222
|
+
visibleCount,
|
|
223
|
+
hits.length
|
|
224
|
+
]);
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
const list = listRef.current;
|
|
227
|
+
if (list !== null && list.scrollTop !== 0) list.scrollTop = 0;
|
|
228
|
+
}, [hits]);
|
|
164
229
|
useEffect(() => {
|
|
165
230
|
if (hits.length === 0) return;
|
|
231
|
+
if (!movedByKeyboard.current) return;
|
|
232
|
+
movedByKeyboard.current = false;
|
|
166
233
|
(listRef.current?.querySelector(`#${CSS.escape(`${baseId}-option-${activeIndex}`)}`))?.scrollIntoView({ block: "nearest" });
|
|
167
234
|
}, [
|
|
168
235
|
activeIndex,
|
|
@@ -179,6 +246,7 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
179
246
|
if (hits.length === 0) return;
|
|
180
247
|
event.preventDefault();
|
|
181
248
|
const delta = event.key === "ArrowDown" ? 1 : -1;
|
|
249
|
+
movedByKeyboard.current = true;
|
|
182
250
|
setActiveIndex((index) => (index + delta + hits.length) % hits.length);
|
|
183
251
|
return;
|
|
184
252
|
}
|
|
@@ -250,11 +318,16 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
250
318
|
className: "wave-docs-search-results",
|
|
251
319
|
role: "listbox",
|
|
252
320
|
"aria-label": dialogLabel,
|
|
253
|
-
children: hits.map((hit, index) => /* @__PURE__ */ jsx(SearchResultOption, {
|
|
321
|
+
children: hits.slice(0, visibleCount).map((hit, index) => /* @__PURE__ */ jsx(SearchResultOption, {
|
|
254
322
|
hit,
|
|
255
323
|
id: optionId(index),
|
|
256
324
|
isActive: index === activeIndex,
|
|
257
|
-
|
|
325
|
+
setSize: hits.length,
|
|
326
|
+
posInSet: index + 1,
|
|
327
|
+
onActivate: () => {
|
|
328
|
+
movedByKeyboard.current = false;
|
|
329
|
+
setActiveIndex(index);
|
|
330
|
+
},
|
|
258
331
|
onSelect: selectHit,
|
|
259
332
|
...Link === void 0 ? {} : { Link }
|
|
260
333
|
}, hit.id))
|
|
@@ -262,40 +335,45 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
262
335
|
/* @__PURE__ */ jsx(SearchStatus, {
|
|
263
336
|
status,
|
|
264
337
|
query: query.trim(),
|
|
265
|
-
hitCount: hits.length
|
|
338
|
+
hitCount: hits.length,
|
|
339
|
+
minQueryLength,
|
|
340
|
+
labels: {
|
|
341
|
+
...hintLabel === void 0 ? {} : { hint: hintLabel },
|
|
342
|
+
...shortQueryLabel === void 0 ? {} : { shortQuery: shortQueryLabel },
|
|
343
|
+
...loadingLabel === void 0 ? {} : { loading: loadingLabel },
|
|
344
|
+
...errorLabel === void 0 ? {} : { error: errorLabel },
|
|
345
|
+
...emptyLabel === void 0 ? {} : { empty: emptyLabel }
|
|
346
|
+
},
|
|
347
|
+
resultCountLabels,
|
|
348
|
+
locale
|
|
266
349
|
})
|
|
267
350
|
]
|
|
268
351
|
})
|
|
269
352
|
}), document.body) : null] });
|
|
270
353
|
}
|
|
271
354
|
/** One result row: a real link, so middle-click and "open in new tab" work. */
|
|
272
|
-
function SearchResultOption({ hit, id, isActive, onActivate, onSelect, Link }) {
|
|
355
|
+
function SearchResultOption({ hit, id, isActive, setSize, posInSet, onActivate, onSelect, Link }) {
|
|
273
356
|
function handleClick(event) {
|
|
274
357
|
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
275
358
|
event.preventDefault();
|
|
276
359
|
onSelect(hit);
|
|
277
360
|
}
|
|
278
|
-
const trail = toBreadcrumbs(hit);
|
|
279
361
|
const body = /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
|
|
280
362
|
className: "wave-docs-search-result-heading",
|
|
281
363
|
children: hit.heading
|
|
282
|
-
}),
|
|
283
|
-
className: "wave-docs-search-result-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
children: [index === 0 ? null : /* @__PURE__ */ jsx("span", {
|
|
287
|
-
className: "wave-docs-search-result-crumb-separator",
|
|
288
|
-
"aria-hidden": "true",
|
|
289
|
-
children: "›"
|
|
290
|
-
}), crumb.text]
|
|
291
|
-
}, crumb.key))
|
|
364
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
365
|
+
className: "wave-docs-search-result-location",
|
|
366
|
+
"aria-hidden": "true",
|
|
367
|
+
children: toDisplayPath(hit.href)
|
|
292
368
|
})] });
|
|
293
369
|
return /* @__PURE__ */ jsx("div", {
|
|
294
370
|
id,
|
|
295
371
|
className: isActive ? "wave-docs-search-result wave-docs-search-result-active" : "wave-docs-search-result",
|
|
296
372
|
role: "option",
|
|
297
373
|
"aria-selected": isActive,
|
|
298
|
-
"aria-
|
|
374
|
+
"aria-setsize": setSize,
|
|
375
|
+
"aria-posinset": posInSet,
|
|
376
|
+
"aria-label": spokenName(hit),
|
|
299
377
|
tabIndex: -1,
|
|
300
378
|
onPointerMove: onActivate,
|
|
301
379
|
children: Link === void 0 ? /* @__PURE__ */ jsx("a", {
|
|
@@ -313,21 +391,59 @@ function SearchResultOption({ hit, id, isActive, onActivate, onSelect, Link }) {
|
|
|
313
391
|
})
|
|
314
392
|
});
|
|
315
393
|
}
|
|
394
|
+
/**
|
|
395
|
+
* The wording, so a caller that overrides none of it costs nothing.
|
|
396
|
+
*
|
|
397
|
+
* `Record<keyof …, string>` rather than `as const`: literal types here make
|
|
398
|
+
* every override a type error, and `Required<StatusLabels>` keeps the
|
|
399
|
+
* `| undefined` that `exactOptionalPropertyTypes` needs on the props.
|
|
400
|
+
*/
|
|
401
|
+
const DEFAULT_STATUS_LABELS = {
|
|
402
|
+
hint: "Start typing to search the documentation.",
|
|
403
|
+
shortQuery: "Keep typing — {min} characters or more.",
|
|
404
|
+
loading: "Loading the search index…",
|
|
405
|
+
error: "Search is unavailable right now. Try reloading the page.",
|
|
406
|
+
empty: "No results for “{query}”."
|
|
407
|
+
};
|
|
408
|
+
const DEFAULT_RESULT_COUNT_LABELS = {
|
|
409
|
+
one: "{count} result",
|
|
410
|
+
other: "{count} results"
|
|
411
|
+
};
|
|
412
|
+
/**
|
|
413
|
+
* The announcement for `count` hits, in the document's own language.
|
|
414
|
+
*
|
|
415
|
+
* `Intl.PluralRules` rather than an `=== 1` check: Polish takes four plural
|
|
416
|
+
* forms and Arabic six, and a package that ships an English singular/plural pair
|
|
417
|
+
* announces a wrong number of results — correctly, and confidently — in most of
|
|
418
|
+
* the world. An unlisted category falls back to `other`, which is the one every
|
|
419
|
+
* language has.
|
|
420
|
+
*/
|
|
421
|
+
function announceCount(count, labels, locale) {
|
|
422
|
+
const tag = locale ?? (typeof document === "undefined" ? "" : document.documentElement.lang) ?? "";
|
|
423
|
+
let category = "other";
|
|
424
|
+
try {
|
|
425
|
+
category = new Intl.PluralRules(tag === "" ? "en" : tag).select(count);
|
|
426
|
+
} catch {}
|
|
427
|
+
return (labels[category] ?? labels.other ?? "{count}").replace("{count}", String(count));
|
|
428
|
+
}
|
|
316
429
|
/** Loading, failure and empty states, plus a live region for hit counts. */
|
|
317
|
-
function SearchStatus({ status, query, hitCount }) {
|
|
430
|
+
function SearchStatus({ status, query, hitCount, minQueryLength, labels, resultCountLabels, locale }) {
|
|
318
431
|
let message = null;
|
|
319
432
|
let modifier = "";
|
|
320
433
|
if (status === "error") {
|
|
321
|
-
message =
|
|
434
|
+
message = labels.error ?? DEFAULT_STATUS_LABELS.error;
|
|
322
435
|
modifier = " wave-docs-search-status-error";
|
|
323
436
|
} else if (query === "") {
|
|
324
|
-
message =
|
|
437
|
+
message = labels.hint ?? DEFAULT_STATUS_LABELS.hint;
|
|
438
|
+
modifier = " wave-docs-search-status-hint";
|
|
439
|
+
} else if (query.length < minQueryLength) {
|
|
440
|
+
message = (labels.shortQuery ?? DEFAULT_STATUS_LABELS.shortQuery).replace("{min}", String(minQueryLength));
|
|
325
441
|
modifier = " wave-docs-search-status-hint";
|
|
326
442
|
} else if (status !== "ready") {
|
|
327
|
-
message =
|
|
443
|
+
message = labels.loading ?? DEFAULT_STATUS_LABELS.loading;
|
|
328
444
|
modifier = " wave-docs-search-status-loading";
|
|
329
445
|
} else if (hitCount === 0) {
|
|
330
|
-
message =
|
|
446
|
+
message = (labels.empty ?? DEFAULT_STATUS_LABELS.empty).replace("{query}", query);
|
|
331
447
|
modifier = " wave-docs-search-status-empty";
|
|
332
448
|
}
|
|
333
449
|
return /* @__PURE__ */ jsxs(Fragment$1, { children: [message === null ? null : /* @__PURE__ */ jsx("p", {
|
|
@@ -337,7 +453,7 @@ function SearchStatus({ status, query, hitCount }) {
|
|
|
337
453
|
className: "wave-docs-search-announcer",
|
|
338
454
|
role: "status",
|
|
339
455
|
"aria-live": "polite",
|
|
340
|
-
children: query === "" || status !== "ready" ? "" :
|
|
456
|
+
children: query === "" || status !== "ready" ? "" : announceCount(hitCount, resultCountLabels ?? DEFAULT_RESULT_COUNT_LABELS, locale)
|
|
341
457
|
})] });
|
|
342
458
|
}
|
|
343
459
|
/**
|
|
@@ -380,24 +496,47 @@ function isSearchHit(hit) {
|
|
|
380
496
|
return hit !== void 0;
|
|
381
497
|
}
|
|
382
498
|
/**
|
|
383
|
-
*
|
|
384
|
-
*
|
|
499
|
+
* The route, without its anchor, for display only.
|
|
500
|
+
*
|
|
501
|
+
* ⚠️ THE ANCHOR IS NOISE HERE, AND ALMOST ALWAYS A REPEAT. A section's anchor
|
|
502
|
+
* is slugged from its heading, so `/docs/styling#layout-tokens` under a row
|
|
503
|
+
* whose first line already reads "Layout tokens" spends its width restating
|
|
504
|
+
* it — and on a real site it is the part that pushes the line past the
|
|
505
|
+
* ellipsis.
|
|
385
506
|
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
507
|
+
* What the line is for is "which page does this land on", and the path answers
|
|
508
|
+
* that on its own. Two rows from the same page showing the same path is not
|
|
509
|
+
* ambiguity: they *are* the same page, and their headings above say which part.
|
|
510
|
+
*
|
|
511
|
+
* Display only. `hit.href` keeps the anchor, so the link still deep-links to
|
|
512
|
+
* the section — that is the whole point of section-scoped records.
|
|
390
513
|
*/
|
|
391
|
-
function
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
514
|
+
function toDisplayPath(href) {
|
|
515
|
+
const hash = href.indexOf("#");
|
|
516
|
+
return hash === -1 ? href : href.slice(0, hash);
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* What a result is called when it is read aloud.
|
|
520
|
+
*
|
|
521
|
+
* Words, not the route the row displays. `/docs/styling#layout-tokens` is
|
|
522
|
+
* punctuation to a screen reader — spelled out slash by slash — so the visible
|
|
523
|
+
* line and the announced name deliberately carry the same fact in two forms:
|
|
524
|
+
* the route for a sighted reader scanning for where a hit lands, and
|
|
525
|
+
* "Layout tokens, Styling" for a listener.
|
|
526
|
+
*
|
|
527
|
+
* `ancestors` deliberately excludes the page title, so the page comes first
|
|
528
|
+
* here and the enclosing headings follow, outermost first.
|
|
529
|
+
*
|
|
530
|
+
* A page's own record carries `heading === title` and no ancestors, so its name
|
|
531
|
+
* is the heading alone — "Styling, Styling" is not a path, it is a stutter.
|
|
532
|
+
*/
|
|
533
|
+
function spokenName(hit) {
|
|
534
|
+
if (hit.ancestors.length === 0 && hit.heading === hit.title) return hit.heading;
|
|
535
|
+
return [
|
|
536
|
+
hit.heading,
|
|
537
|
+
hit.title,
|
|
538
|
+
...hit.ancestors
|
|
539
|
+
].join(", ");
|
|
401
540
|
}
|
|
402
541
|
function trapFocus(root, event) {
|
|
403
542
|
if (root === null) return;
|
|
@@ -1,22 +1,45 @@
|
|
|
1
1
|
//#region src/react/shell-labels.d.ts
|
|
2
2
|
/**
|
|
3
|
-
* Every user-visible string
|
|
3
|
+
* Every user-visible string this package renders that is not the reader's
|
|
4
|
+
* content.
|
|
4
5
|
*
|
|
5
|
-
* ⚠️ THERE
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* ⚠️ THERE USED TO BE FOUR, AND THE DOCSTRING CLAIMED THEY WERE ALL OF THEM.
|
|
7
|
+
* They were the four `docs.Layout` renders directly, and the claim — "the whole
|
|
8
|
+
* of what a non-English site has to say" — was false by seventeen strings. A
|
|
9
|
+
* German site built exactly the documented way shipped
|
|
10
|
+
* `<nav aria-label="On this page">`, a visible `Back to top`, `aria-label="Tip"`
|
|
11
|
+
* on every callout, `Copy code` on every fence and `(opens in a new tab)` after
|
|
12
|
+
* every external link. Verified in this repository's own `site/out`, which is
|
|
13
|
+
* how it was found.
|
|
11
14
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
+
* ## Where each one is rendered, because it decides the cost
|
|
16
|
+
*
|
|
17
|
+
* Most are emitted by Server Components or baked into the HTML by a rehype
|
|
18
|
+
* plugin at build time, so overriding them costs nothing at all. Six cross into
|
|
19
|
+
* a Client Component — the two sidebar disclosure verbs, the two table-of-contents
|
|
20
|
+
* strings and the two copy-status messages — and those are forwarded ONLY when
|
|
21
|
+
* set, so a site that overrides nothing carries exactly the payload it did
|
|
22
|
+
* before.
|
|
23
|
+
*
|
|
24
|
+
* ## The defaults do not all live here
|
|
25
|
+
*
|
|
26
|
+
* {@link DEFAULT_DOCS_LABELS} covers the four shell strings and no more, on
|
|
27
|
+
* purpose. The rest default inside the component that renders them, because
|
|
28
|
+
* this module would otherwise have to be imported by `sidebar.tsx` and
|
|
29
|
+
* `code-runtime.tsx` — and an object literal of twenty-one strings does not
|
|
30
|
+
* tree-shake, so every reader would download the German site's English
|
|
31
|
+
* fallbacks. `next.test.ts` asserts that every key here reaches the output, so
|
|
32
|
+
* a key that is declared and never wired fails rather than reading as
|
|
33
|
+
* configuration.
|
|
15
34
|
*
|
|
16
35
|
* Not here: the search dialog's strings, which are reachable through
|
|
17
|
-
* `search={{ … }}
|
|
18
|
-
*
|
|
19
|
-
*
|
|
36
|
+
* `search={{ … }}` on `docs.Layout` — its trigger, its placeholder, its
|
|
37
|
+
* accessible name and its five state messages, plus the plural forms of its
|
|
38
|
+
* live region. They travel with the dialog's own props rather than with these
|
|
39
|
+
* because that channel already existed and already carries `pageSize` and
|
|
40
|
+
* `minQueryLength`; a second route to the same component would be two places to
|
|
41
|
+
* look. `DocsSidebar`'s own `label` is likewise public API, for anyone composing
|
|
42
|
+
* a shell by hand.
|
|
20
43
|
*/
|
|
21
44
|
interface DocsLabels {
|
|
22
45
|
/** The navigation landmark's accessible name. Default `'Documentation'`. */
|
|
@@ -27,17 +50,108 @@ interface DocsLabels {
|
|
|
27
50
|
closeNav?: string | undefined;
|
|
28
51
|
/** The skip link's visible text. Default `'Skip to content'`. */
|
|
29
52
|
skipToContent?: string | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* Accessible name of a collapsed group's toggle. Default `'Expand {title}'`.
|
|
55
|
+
*
|
|
56
|
+
* `{title}` is replaced with the group's own name. A placeholder rather than a
|
|
57
|
+
* function because this crosses from a Server Component to a Client one, where
|
|
58
|
+
* a function cannot go.
|
|
59
|
+
*/
|
|
60
|
+
expandGroup?: string | undefined;
|
|
61
|
+
/** The same toggle when open. Default `'Collapse {title}'`. */
|
|
62
|
+
collapseGroup?: string | undefined;
|
|
63
|
+
/** The TOC landmark's accessible name. Default `'On this page'`. */
|
|
64
|
+
toc?: string | undefined;
|
|
65
|
+
/** The link at the end of the TOC. Default `'Back to top'`. */
|
|
66
|
+
backToTop?: string | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* Screen-reader suffix on a link that opens a new tab.
|
|
69
|
+
* Default `'(opens in a new tab)'`.
|
|
70
|
+
*/
|
|
71
|
+
externalLink?: string | undefined;
|
|
72
|
+
/**
|
|
73
|
+
* Accessible name of a wide table's scroll region. Default `'Table'`.
|
|
74
|
+
*
|
|
75
|
+
* The region exists so a keyboard user can scroll a table that overflows; an
|
|
76
|
+
* unnamed one is announced as "region", which says nothing about what it
|
|
77
|
+
* holds.
|
|
78
|
+
*/
|
|
79
|
+
table?: string | undefined;
|
|
80
|
+
/** Heading on a `> [!NOTE]` callout. Default `'Note'`. */
|
|
81
|
+
calloutNote?: string | undefined;
|
|
82
|
+
/** Heading on a `> [!TIP]` callout. Default `'Tip'`. */
|
|
83
|
+
calloutTip?: string | undefined;
|
|
84
|
+
/** Heading on a `> [!IMPORTANT]` callout. Default `'Important'`. */
|
|
85
|
+
calloutImportant?: string | undefined;
|
|
86
|
+
/** Heading on a `> [!WARNING]` callout. Default `'Warning'`. */
|
|
87
|
+
calloutWarning?: string | undefined;
|
|
88
|
+
/** Heading on a `> [!CAUTION]` callout. Default `'Caution'`. */
|
|
89
|
+
calloutCaution?: string | undefined;
|
|
90
|
+
/**
|
|
91
|
+
* Accessible name of a YouTube embed. Default `'YouTube video player'`.
|
|
92
|
+
*
|
|
93
|
+
* Markdown carries no video title, so this is the name every embed on the site
|
|
94
|
+
* gets unless a `<YouTube title>` overrides it.
|
|
95
|
+
*/
|
|
96
|
+
youtubeTitle?: string | undefined;
|
|
97
|
+
/**
|
|
98
|
+
* The closed facade's control. Default `'Play video: {title}'`.
|
|
99
|
+
*
|
|
100
|
+
* `{title}` is the embed's accessible name.
|
|
101
|
+
*/
|
|
102
|
+
youtubePlay?: string | undefined;
|
|
103
|
+
/** The open facade's control. Default `'Hide video: {title}'`. */
|
|
104
|
+
youtubeHide?: string | undefined;
|
|
105
|
+
/** The copy button on a fence with no title. Default `'Copy code'`. */
|
|
106
|
+
copyCode?: string | undefined;
|
|
107
|
+
/**
|
|
108
|
+
* The copy button on a titled fence. Default `'Copy code from {title}'`.
|
|
109
|
+
*
|
|
110
|
+
* `{title}` is the fence's own `title="…"`. Two controls both called "Copy
|
|
111
|
+
* code" are indistinguishable in a screen reader's element list, which is why
|
|
112
|
+
* the titled form exists at all.
|
|
113
|
+
*/
|
|
114
|
+
copyCodeFrom?: string | undefined;
|
|
115
|
+
/** Announced after a successful copy. Default `'Copied to the clipboard.'` */
|
|
116
|
+
copied?: string | undefined;
|
|
117
|
+
/**
|
|
118
|
+
* Announced after a failed one. Default
|
|
119
|
+
* `'Copy failed. Select the code and press Control or Command + C.'`
|
|
120
|
+
*
|
|
121
|
+
* Says what to do instead, not merely that it failed: the common way to land
|
|
122
|
+
* here is `next dev` on a phone over plain HTTP, where there is no secure
|
|
123
|
+
* context and retrying cannot help.
|
|
124
|
+
*/
|
|
125
|
+
copyFailed?: string | undefined;
|
|
30
126
|
}
|
|
31
127
|
/**
|
|
32
|
-
*
|
|
128
|
+
* Every key of {@link DocsLabels}, for the test that proves each one is wired.
|
|
33
129
|
*
|
|
34
|
-
* A
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* `'Documentation'` — two names for one region, depending on the viewport.
|
|
130
|
+
* A list rather than a type-level trick because the assertion has to run: the
|
|
131
|
+
* failure being guarded against is a key that is declared, documented and never
|
|
132
|
+
* read, which type-checks perfectly.
|
|
38
133
|
*/
|
|
39
|
-
declare const
|
|
134
|
+
declare const DOCS_LABEL_KEYS: readonly ["nav", "openNav", "closeNav", "skipToContent", "expandGroup", "collapseGroup", "toc", "backToTop", "externalLink", "table", "calloutNote", "calloutTip", "calloutImportant", "calloutWarning", "calloutCaution", "youtubeTitle", "youtubePlay", "youtubeHide", "copyCode", "copyCodeFrom", "copied", "copyFailed"];
|
|
135
|
+
/** The four the shell renders itself, resolved centrally. */
|
|
136
|
+
type ShellLabelKey = 'nav' | 'openNav' | 'closeNav' | 'skipToContent';
|
|
137
|
+
/**
|
|
138
|
+
* Defaults for the shell's four, in one place.
|
|
139
|
+
*
|
|
140
|
+
* A `Required<…>` rather than four `=` defaults spread across three components:
|
|
141
|
+
* the previous arrangement is how `DocsSidebar` came to default its landmark to
|
|
142
|
+
* `'Docs'` while `DocsNav` defaulted the same landmark to `'Documentation'` —
|
|
143
|
+
* two names for one region, depending on the viewport.
|
|
144
|
+
*/
|
|
145
|
+
declare const DEFAULT_DOCS_LABELS: Required<Pick<DocsLabels, ShellLabelKey>>;
|
|
40
146
|
/** The given labels over the defaults, with `undefined` treated as unset. */
|
|
41
|
-
declare function resolveLabels(labels: DocsLabels | undefined): Required<DocsLabels
|
|
147
|
+
declare function resolveLabels(labels: DocsLabels | undefined): Required<Pick<DocsLabels, ShellLabelKey>>;
|
|
148
|
+
/**
|
|
149
|
+
* `template` with `{title}` replaced.
|
|
150
|
+
*
|
|
151
|
+
* A placeholder, because these strings cross a Server → Client boundary where a
|
|
152
|
+
* function cannot go — and because a translator needs to move the name within
|
|
153
|
+
* the sentence, which string concatenation does not allow.
|
|
154
|
+
*/
|
|
155
|
+
declare function fillTitle(template: string, title: string): string;
|
|
42
156
|
//#endregion
|
|
43
|
-
export { DEFAULT_DOCS_LABELS, DocsLabels, resolveLabels };
|
|
157
|
+
export { DEFAULT_DOCS_LABELS, DOCS_LABEL_KEYS, DocsLabels, ShellLabelKey, fillTitle, resolveLabels };
|