@grimoire-rs/indexer 0.1.8 → 0.2.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/README.md +38 -0
- package/dist/cli/init.js +1 -1
- package/dist/cli/init.js.map +1 -1
- package/dist/config.d.ts +21 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +19 -2
- package/dist/config.js.map +1 -1
- package/dist/enrich/index.d.ts.map +1 -1
- package/dist/enrich/index.js +62 -0
- package/dist/enrich/index.js.map +1 -1
- package/dist/renderer/astro/components/BrandMark.d.ts +14 -0
- package/dist/renderer/astro/components/BrandMark.d.ts.map +1 -0
- package/dist/renderer/astro/components/BrandMark.js +16 -0
- package/dist/renderer/astro/components/BrandMark.js.map +1 -0
- package/dist/renderer/astro/components/BrandMark.tsx +19 -0
- package/dist/renderer/astro/components/Catalog.d.ts.map +1 -1
- package/dist/renderer/astro/components/Catalog.js +310 -18
- package/dist/renderer/astro/components/Catalog.js.map +1 -1
- package/dist/renderer/astro/components/Catalog.tsx +445 -63
- package/dist/renderer/astro/components/CommandField.astro +43 -0
- package/dist/renderer/astro/components/PickerMenu.astro +68 -0
- package/dist/renderer/astro/components/VersionMenu.astro +6 -2
- package/dist/renderer/astro/content.config.ts +23 -5
- package/dist/renderer/astro/layouts/Base.astro +794 -66
- package/dist/renderer/astro/lib/catalog.d.ts +24 -0
- package/dist/renderer/astro/lib/catalog.d.ts.map +1 -1
- package/dist/renderer/astro/lib/catalog.js +62 -0
- package/dist/renderer/astro/lib/catalog.js.map +1 -1
- package/dist/renderer/astro/lib/catalog.ts +62 -0
- package/dist/renderer/astro/lib/code.d.ts +22 -0
- package/dist/renderer/astro/lib/code.d.ts.map +1 -0
- package/dist/renderer/astro/lib/code.js +21 -0
- package/dist/renderer/astro/lib/code.js.map +1 -0
- package/dist/renderer/astro/lib/code.ts +21 -0
- package/dist/renderer/astro/pages/index.astro +144 -36
- package/dist/renderer/astro/pages/p/[...slug].astro +403 -55
- package/dist/renderer/index.d.ts +23 -0
- package/dist/renderer/index.d.ts.map +1 -1
- package/dist/renderer/index.js +117 -30
- package/dist/renderer/index.js.map +1 -1
- package/package.json +5 -1
- package/dist/renderer/astro/components/CopyButton.astro +0 -24
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import { useMemo, useState } from "preact/hooks";
|
|
1
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
|
2
|
+
// Lucide (ISC) draws the UI; brand marks come from `@mdi/js`, which Lucide
|
|
3
|
+
// deliberately does not carry. No SVG on this site is hand-written.
|
|
4
|
+
import { Check, FolderRoot, Globe, Image, ImageOff } from "lucide-preact";
|
|
5
|
+
import { mdiMicrosoftVisualStudioCode } from "@mdi/js";
|
|
6
|
+
import { BrandMark } from "./BrandMark.js";
|
|
2
7
|
import { withBase } from "../lib/base.js";
|
|
3
8
|
import { timeAgo, vscodeUrl, type CatalogPackage } from "../lib/catalog.js";
|
|
4
9
|
|
|
@@ -23,28 +28,136 @@ function compare(a: CatalogPackage, b: CatalogPackage, sort: Sort): number {
|
|
|
23
28
|
return new Date(b.created).getTime() - new Date(a.created).getTime();
|
|
24
29
|
}
|
|
25
30
|
|
|
26
|
-
// The two install scopes
|
|
27
|
-
// "project" and "global" read identically in both
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
// The two install scopes previously wore the VS Code extension's own
|
|
32
|
+
// codicons so "project" and "global" read identically in both. That parity
|
|
33
|
+
// is gone on purpose: every icon now comes from one set. `FolderRoot` and
|
|
34
|
+
// `Globe` are the nearest Lucide equivalents and carry the same meaning.
|
|
35
|
+
|
|
36
|
+
/** Typing inside one of these means a bare keystroke is text, not a shortcut. */
|
|
37
|
+
function isTyping(el: EventTarget | null): boolean {
|
|
38
|
+
const node = el as HTMLElement | null;
|
|
39
|
+
if (!node) return false;
|
|
40
|
+
return node.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(node.tagName);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* How many cards the grid actually laid out per row.
|
|
45
|
+
*
|
|
46
|
+
* Measured, not read from CSS: the track list is `auto-fill` with a minimum
|
|
47
|
+
* width, so the count is a layout outcome that depends on the viewport. The
|
|
48
|
+
* first card whose top edge drops below the first row's starts row two.
|
|
49
|
+
*/
|
|
50
|
+
function columnCount(cards: HTMLElement[]): number {
|
|
51
|
+
if (cards.length < 2) return 1;
|
|
52
|
+
const top = cards[0]!.offsetTop;
|
|
53
|
+
const wrapped = cards.findIndex((card) => card.offsetTop > top);
|
|
54
|
+
return wrapped === -1 ? cards.length : wrapped;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The card's 28px logo slot, in its three states.
|
|
59
|
+
*
|
|
60
|
+
* The third one is the reason this is a component rather than inline JSX: a
|
|
61
|
+
* package can declare a `logo` whose file is not actually served — the
|
|
62
|
+
* enrich step failed, the asset was pruned, the path is stale — and the
|
|
63
|
+
* browser's own broken-image glyph is both ugly and says nothing. So a
|
|
64
|
+
* declared-but-unreachable logo degrades to a marked placeholder, which is
|
|
65
|
+
* deliberately *not* the same as the initial-letter tile a package with no
|
|
66
|
+
* logo at all gets: one is a fault worth seeing, the other is normal.
|
|
67
|
+
*
|
|
68
|
+
* The static detail page needs the same treatment but cannot use `onError`,
|
|
69
|
+
* so it opts into the global handler in `Base.astro` instead — keep the two
|
|
70
|
+
* placeholders looking alike.
|
|
71
|
+
*/
|
|
72
|
+
function CardLogo({ pkg }: { pkg: CatalogPackage }) {
|
|
73
|
+
const [state, setState] = useState<"loading" | "ready" | "broken">("loading");
|
|
74
|
+
const imgRef = useRef<HTMLImageElement>(null);
|
|
75
|
+
|
|
76
|
+
// The image is server-rendered, so the browser begins fetching it while
|
|
77
|
+
// parsing the HTML — long before this island hydrates. Two consequences,
|
|
78
|
+
// and the slot markup below answers both: `onError` can fire before any
|
|
79
|
+
// listener exists (the placeholder used to appear only sometimes), and a
|
|
80
|
+
// failed image paints the browser's broken glyph on the way (the flash on
|
|
81
|
+
// reload). Starting the image hidden means nothing is ever shown until it
|
|
82
|
+
// is known to be good.
|
|
83
|
+
//
|
|
84
|
+
// `complete` says the browser finished, not how it went. `decode()` is
|
|
85
|
+
// what separates the two: it rejects for a failure and resolves for a good
|
|
86
|
+
// image — including an SVG with no intrinsic size, where the usual
|
|
87
|
+
// `naturalWidth === 0` test reports a false failure. Gating on `complete`
|
|
88
|
+
// means it never starts a fetch, so `loading="lazy"` still holds off
|
|
89
|
+
// -screen cards.
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
setState("loading");
|
|
92
|
+
const img = imgRef.current;
|
|
93
|
+
if (!img?.complete) return;
|
|
94
|
+
let live = true;
|
|
95
|
+
img.decode().then(
|
|
96
|
+
() => live && setState("ready"),
|
|
97
|
+
() => live && setState("broken"),
|
|
98
|
+
);
|
|
99
|
+
return () => {
|
|
100
|
+
live = false;
|
|
101
|
+
};
|
|
102
|
+
}, [pkg.logo]);
|
|
103
|
+
|
|
104
|
+
if (!pkg.logo) {
|
|
105
|
+
return (
|
|
106
|
+
<span
|
|
107
|
+
class="card-logo card-logo-fallback"
|
|
108
|
+
aria-hidden="true"
|
|
109
|
+
style={{ background: `var(--kind-${pkg.kind}, var(--muted))` }}
|
|
110
|
+
>
|
|
111
|
+
{pkg.name[0]?.toUpperCase()}
|
|
112
|
+
</span>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<span
|
|
118
|
+
class="card-logo logo-slot"
|
|
119
|
+
data-state={state}
|
|
120
|
+
role={state === "broken" ? "img" : undefined}
|
|
121
|
+
aria-label={state === "broken" ? "Logo image unavailable" : undefined}
|
|
122
|
+
title={state === "broken" ? "Logo image unavailable" : undefined}
|
|
123
|
+
>
|
|
124
|
+
{state === "broken" ? (
|
|
125
|
+
<ImageOff class="logo-mark" aria-hidden="true" />
|
|
126
|
+
) : (
|
|
127
|
+
<Image class="logo-mark" aria-hidden="true" />
|
|
128
|
+
)}
|
|
129
|
+
<img
|
|
130
|
+
ref={imgRef}
|
|
131
|
+
src={withBase(pkg.logo)}
|
|
132
|
+
alt=""
|
|
133
|
+
loading="lazy"
|
|
134
|
+
onLoad={() => setState("ready")}
|
|
135
|
+
onError={() => setState("broken")}
|
|
136
|
+
/>
|
|
137
|
+
</span>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
36
140
|
|
|
37
141
|
function CopyButton({
|
|
38
142
|
command,
|
|
39
143
|
variant = "default",
|
|
144
|
+
name,
|
|
40
145
|
}: {
|
|
41
146
|
command: string;
|
|
42
147
|
variant?: "default" | "global";
|
|
148
|
+
/** What the copy toast calls this, e.g. `"global install command"`. */
|
|
149
|
+
name?: string;
|
|
43
150
|
}) {
|
|
44
151
|
const [copied, setCopied] = useState(false);
|
|
45
152
|
const copy = () => {
|
|
46
153
|
navigator.clipboard.writeText(command).then(() => {
|
|
47
154
|
setCopied(true);
|
|
155
|
+
// The toast lives in Base.astro's inline script, outside this island —
|
|
156
|
+
// an event is how a hydrated component reaches it without either side
|
|
157
|
+
// importing the other.
|
|
158
|
+
document.dispatchEvent(
|
|
159
|
+
new CustomEvent("grimoire:copied", { detail: { name, value: command } }),
|
|
160
|
+
);
|
|
48
161
|
setTimeout(() => setCopied(false), 1500);
|
|
49
162
|
});
|
|
50
163
|
};
|
|
@@ -54,11 +167,12 @@ function CopyButton({
|
|
|
54
167
|
class={copied ? "copy copied" : "copy"}
|
|
55
168
|
title={command}
|
|
56
169
|
aria-label={`Copy: ${command}`}
|
|
170
|
+
// Out of the Tab sequence: the card is the stop, and the same command
|
|
171
|
+
// is copyable from the detail page Enter opens.
|
|
172
|
+
tabIndex={-1}
|
|
57
173
|
onClick={copy}
|
|
58
174
|
>
|
|
59
|
-
<
|
|
60
|
-
<path d={copied ? CHECK : variant === "global" ? SCOPE_GLOBAL : SCOPE_PROJECT} />
|
|
61
|
-
</svg>
|
|
175
|
+
{copied ? <Check size={14} /> : variant === "global" ? <Globe size={14} /> : <FolderRoot size={14} />}
|
|
62
176
|
</button>
|
|
63
177
|
);
|
|
64
178
|
}
|
|
@@ -73,49 +187,282 @@ export default function Catalog({
|
|
|
73
187
|
packages: CatalogPackage[];
|
|
74
188
|
vscodeExtension: string | null;
|
|
75
189
|
}) {
|
|
76
|
-
|
|
190
|
+
// Seeded from `?q=…` on the very first render, not from an effect: a
|
|
191
|
+
// keyword chip on a package page links here, and filtering one render late
|
|
192
|
+
// means painting the full catalog and then collapsing it. The server has
|
|
193
|
+
// no `location`, so it renders the unfiltered list — which is what a
|
|
194
|
+
// crawler and a `?q=`-less visitor should both get.
|
|
195
|
+
const [query, setQuery] = useState(() =>
|
|
196
|
+
typeof location === "undefined" ? "" : (new URLSearchParams(location.search).get("q") ?? ""),
|
|
197
|
+
);
|
|
77
198
|
const [kind, setKind] = useState<string | null>(null);
|
|
78
199
|
const [sort, setSort] = useState<Sort>("name");
|
|
200
|
+
// Deprecated packages are hidden until asked for: a retired package is
|
|
201
|
+
// noise for someone browsing what to install, and the publisher already
|
|
202
|
+
// said as much by deprecating it.
|
|
203
|
+
const [showDeprecated, setShowDeprecated] = useState(false);
|
|
204
|
+
|
|
205
|
+
const searchRef = useRef<HTMLInputElement>(null);
|
|
206
|
+
const gridRef = useRef<HTMLUListElement>(null);
|
|
207
|
+
const controlsRef = useRef<HTMLDivElement>(null);
|
|
208
|
+
|
|
209
|
+
const cardsOf = () => [...(gridRef.current?.querySelectorAll<HTMLElement>("li.card") ?? [])];
|
|
210
|
+
const chipsOf = () => [
|
|
211
|
+
...(controlsRef.current?.querySelectorAll<HTMLElement>("button.chip") ?? []),
|
|
212
|
+
];
|
|
213
|
+
|
|
214
|
+
/** Move focus `delta` cards along, clamping at both ends rather than wrapping. */
|
|
215
|
+
const focusCard = (from: number, delta: number) => {
|
|
216
|
+
const cards = cardsOf();
|
|
217
|
+
if (cards.length === 0) return;
|
|
218
|
+
const next = Math.min(cards.length - 1, Math.max(0, from + delta));
|
|
219
|
+
cards[next]?.focus();
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Focus the search box and bring it to the top of the viewport, so the
|
|
224
|
+
* results — not whatever was on screen before — are what you are looking
|
|
225
|
+
* at while typing. `scroll-margin-top` on the field supplies the gap.
|
|
226
|
+
*
|
|
227
|
+
* Used by every path that moves focus there deliberately (`/`, arrowing up
|
|
228
|
+
* out of the chips, Escape). A plain mouse click is left alone: scrolling
|
|
229
|
+
* the page under a reader who just clicked a visible field is a jolt, not
|
|
230
|
+
* a help.
|
|
231
|
+
*/
|
|
232
|
+
const focusSearch = () => {
|
|
233
|
+
const input = searchRef.current;
|
|
234
|
+
if (!input) return;
|
|
235
|
+
input.focus();
|
|
236
|
+
input.select();
|
|
237
|
+
input.scrollIntoView({
|
|
238
|
+
block: "start",
|
|
239
|
+
behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
|
|
240
|
+
});
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// Base.astro hides the catalog before first paint when the URL carries a
|
|
244
|
+
// query. Reveal it once this render — the filtered one — has hit the DOM.
|
|
245
|
+
// A layout effect, so the reveal lands in the same frame as the content.
|
|
246
|
+
useLayoutEffect(() => {
|
|
247
|
+
// Hydration attaches handlers but does not diff props against the server
|
|
248
|
+
// markup, so the box the server rendered empty stays empty even though
|
|
249
|
+
// this render filtered on `query`. Written straight to the DOM, which is
|
|
250
|
+
// what the vnode already claims.
|
|
251
|
+
const input = searchRef.current;
|
|
252
|
+
if (input && input.value !== query) input.value = query;
|
|
253
|
+
delete document.documentElement.dataset.query;
|
|
254
|
+
}, []);
|
|
255
|
+
|
|
256
|
+
// `/` jumps to the search box, the convention every package registry
|
|
257
|
+
// shares. Bound on the document so it works wherever the reader is.
|
|
258
|
+
useEffect(() => {
|
|
259
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
260
|
+
if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return;
|
|
261
|
+
if (isTyping(event.target)) return;
|
|
262
|
+
event.preventDefault();
|
|
263
|
+
focusSearch();
|
|
264
|
+
};
|
|
265
|
+
document.addEventListener("keydown", onKeyDown);
|
|
266
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
267
|
+
}, []);
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Escape drops the selection, from wherever the reader is — the search
|
|
271
|
+
* box, a chip, a card. "Selection" is both halves of it: the active
|
|
272
|
+
* filters, and the card currently holding focus, which wears the accent
|
|
273
|
+
* border and reads as picked. Clearing one and leaving the other visibly
|
|
274
|
+
* selected is the wrong half.
|
|
275
|
+
*
|
|
276
|
+
* The card is blurred rather than handed back to the search box: Escape
|
|
277
|
+
* means "never mind", not "go here instead", and `/` reaches search from
|
|
278
|
+
* anywhere. It matches what a second Escape in the search box already does.
|
|
279
|
+
*
|
|
280
|
+
* Document-level for the same reason `/` is: this is one piece of state,
|
|
281
|
+
* so the key that clears it should not depend on what happens to hold
|
|
282
|
+
* focus. Bailing on `defaultPrevented` leaves the menus that close
|
|
283
|
+
* themselves on Escape — the platform picker, the version popovers — to do
|
|
284
|
+
* that first without also wiping the catalog.
|
|
285
|
+
*/
|
|
286
|
+
useEffect(() => {
|
|
287
|
+
const onEscape = (event: KeyboardEvent) => {
|
|
288
|
+
if (event.key !== "Escape" || event.defaultPrevented) return;
|
|
289
|
+
const active = document.activeElement;
|
|
290
|
+
// Also true for a control *inside* a card, which is still the card
|
|
291
|
+
// being selected as far as the reader is concerned.
|
|
292
|
+
const card = active instanceof HTMLElement ? active.closest("li.card") : null;
|
|
293
|
+
if (!query && kind === null && !card) return; // nothing selected: not our key
|
|
294
|
+
event.preventDefault();
|
|
295
|
+
setQuery("");
|
|
296
|
+
setKind(null);
|
|
297
|
+
if (card) (active as HTMLElement).blur();
|
|
298
|
+
};
|
|
299
|
+
document.addEventListener("keydown", onEscape);
|
|
300
|
+
return () => document.removeEventListener("keydown", onEscape);
|
|
301
|
+
}, [query, kind]);
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The filter and sort chips are not Tab stops — Tab is reserved for
|
|
305
|
+
* crossing the catalog, so it runs search → card → card. The chips sit in
|
|
306
|
+
* the row above the grid, so they are reached the way that row is:
|
|
307
|
+
* ArrowUp out of the top card row, ArrowDown back into it.
|
|
308
|
+
*
|
|
309
|
+
* The one case that would strand them is an empty result set, where there
|
|
310
|
+
* is no card to arrow up from — so with nothing shown they rejoin the Tab
|
|
311
|
+
* order (see `chipTabIndex` below), which is also exactly when a reader
|
|
312
|
+
* needs them most.
|
|
313
|
+
*/
|
|
314
|
+
const onChipKeyDown = (event: KeyboardEvent) => {
|
|
315
|
+
const chips = chipsOf();
|
|
316
|
+
const index = chips.indexOf(event.currentTarget as HTMLElement);
|
|
317
|
+
if (index === -1) return;
|
|
318
|
+
switch (event.key) {
|
|
319
|
+
case "ArrowRight":
|
|
320
|
+
event.preventDefault();
|
|
321
|
+
return chips[Math.min(chips.length - 1, index + 1)]?.focus();
|
|
322
|
+
case "ArrowLeft":
|
|
323
|
+
event.preventDefault();
|
|
324
|
+
return chips[Math.max(0, index - 1)]?.focus();
|
|
325
|
+
case "ArrowDown":
|
|
326
|
+
event.preventDefault();
|
|
327
|
+
return cardsOf()[0]?.focus();
|
|
328
|
+
case "ArrowUp":
|
|
329
|
+
// Not Escape any more — that clears the filters now, from here as
|
|
330
|
+
// much as anywhere else. ArrowUp is still the way back to search.
|
|
331
|
+
event.preventDefault();
|
|
332
|
+
return focusSearch();
|
|
333
|
+
default:
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const onSearchKeyDown = (event: KeyboardEvent) => {
|
|
339
|
+
if (event.key === "ArrowDown") {
|
|
340
|
+
event.preventDefault();
|
|
341
|
+
cardsOf()[0]?.focus();
|
|
342
|
+
} else if (event.key === "Escape" && !query && kind === null) {
|
|
343
|
+
// Clearing is the document handler's job; this is only the second
|
|
344
|
+
// press, once there is nothing left to clear — so Escape leaves the
|
|
345
|
+
// field rather than being a dead key.
|
|
346
|
+
searchRef.current?.blur();
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const onCardKeyDown = (event: KeyboardEvent) => {
|
|
351
|
+
const card = event.currentTarget as HTMLElement;
|
|
352
|
+
const index = cardsOf().indexOf(card);
|
|
353
|
+
if (index === -1) return;
|
|
354
|
+
|
|
355
|
+
switch (event.key) {
|
|
356
|
+
case "ArrowRight":
|
|
357
|
+
event.preventDefault();
|
|
358
|
+
return focusCard(index, 1);
|
|
359
|
+
case "ArrowLeft":
|
|
360
|
+
event.preventDefault();
|
|
361
|
+
return focusCard(index, -1);
|
|
362
|
+
case "ArrowDown":
|
|
363
|
+
event.preventDefault();
|
|
364
|
+
return focusCard(index, columnCount(cardsOf()));
|
|
365
|
+
case "ArrowUp": {
|
|
366
|
+
event.preventDefault();
|
|
367
|
+
const columns = columnCount(cardsOf());
|
|
368
|
+
// Up out of the first row lands on the control row that sits above
|
|
369
|
+
// the grid — the chips, which have no other keyboard path. Search is
|
|
370
|
+
// one more ArrowUp away, and `/` reaches it from anywhere.
|
|
371
|
+
if (index < columns) {
|
|
372
|
+
const chip = chipsOf()[0];
|
|
373
|
+
if (chip) return chip.focus();
|
|
374
|
+
return focusSearch();
|
|
375
|
+
}
|
|
376
|
+
return focusCard(index, -columns);
|
|
377
|
+
}
|
|
378
|
+
case "Home":
|
|
379
|
+
event.preventDefault();
|
|
380
|
+
return focusCard(index, -index);
|
|
381
|
+
case "End":
|
|
382
|
+
event.preventDefault();
|
|
383
|
+
return focusCard(index, cardsOf().length);
|
|
384
|
+
case "Enter":
|
|
385
|
+
case " ":
|
|
386
|
+
// Only when the card itself holds focus: an inner control reached by
|
|
387
|
+
// mouse must keep its own Space/Enter behaviour.
|
|
388
|
+
if (event.target !== card) return;
|
|
389
|
+
event.preventDefault();
|
|
390
|
+
card.querySelector<HTMLAnchorElement>("h2 a")?.click();
|
|
391
|
+
return;
|
|
392
|
+
default:
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
// The catalog as the kind chips and the search placeholder count it —
|
|
398
|
+
// deprecated entries drop out of those totals too while they are hidden,
|
|
399
|
+
// so no count ever promises more than the grid shows.
|
|
400
|
+
const counted = useMemo(
|
|
401
|
+
() => (showDeprecated ? packages : packages.filter((p) => !p.deprecated)),
|
|
402
|
+
[packages, showDeprecated],
|
|
403
|
+
);
|
|
79
404
|
|
|
80
405
|
const kinds = useMemo(() => {
|
|
81
406
|
const counts = new Map<string, number>();
|
|
82
|
-
for (const p of
|
|
407
|
+
for (const p of counted) counts.set(p.kind, (counts.get(p.kind) ?? 0) + 1);
|
|
83
408
|
return [...counts.entries()].sort(
|
|
84
409
|
(a, b) => kindOrder(a[0]) - kindOrder(b[0]) || a[0].localeCompare(b[0]),
|
|
85
410
|
);
|
|
86
|
-
}, [
|
|
411
|
+
}, [counted]);
|
|
87
412
|
|
|
88
413
|
const q = query.trim().toLowerCase();
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
.
|
|
414
|
+
// Query and kind first, deprecation last — so the toggle can report how
|
|
415
|
+
// many entries *it alone* is holding back, rather than a catalog-wide
|
|
416
|
+
// number that has nothing to do with what is on screen.
|
|
417
|
+
const matching = packages.filter((p) => {
|
|
418
|
+
if (kind && p.kind !== kind) return false;
|
|
419
|
+
if (!q) return true;
|
|
420
|
+
return [
|
|
421
|
+
p.name,
|
|
422
|
+
p.description ?? "",
|
|
423
|
+
p.namespace,
|
|
424
|
+
p.kind,
|
|
425
|
+
p.ref,
|
|
426
|
+
p.summary ?? "",
|
|
427
|
+
(p.keywords ?? []).join(" "),
|
|
428
|
+
].some((field) => field.toLowerCase().includes(q));
|
|
429
|
+
});
|
|
430
|
+
const shown = (showDeprecated ? matching : matching.filter((p) => !p.deprecated)).sort((a, b) =>
|
|
431
|
+
compare(a, b, sort),
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
// A catalog with nothing deprecated gets no toggle — a control that can
|
|
435
|
+
// only ever be a no-op is worse than its absence.
|
|
436
|
+
const hasDeprecated = packages.some((p) => p.deprecated);
|
|
437
|
+
|
|
438
|
+
// Chips leave the Tab order only while there is a grid to arrow up from.
|
|
439
|
+
const chipTabIndex = shown.length === 0 ? 0 : -1;
|
|
104
440
|
|
|
105
441
|
return (
|
|
106
|
-
<section>
|
|
107
|
-
<div class="controls">
|
|
108
|
-
<
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
442
|
+
<section class="catalog">
|
|
443
|
+
<div class="controls" ref={controlsRef}>
|
|
444
|
+
<div class="search-field">
|
|
445
|
+
<input
|
|
446
|
+
ref={searchRef}
|
|
447
|
+
type="search"
|
|
448
|
+
placeholder={`Search ${counted.length} packages…`}
|
|
449
|
+
value={query}
|
|
450
|
+
onInput={(e) => setQuery((e.target as HTMLInputElement).value)}
|
|
451
|
+
onKeyDown={onSearchKeyDown}
|
|
452
|
+
aria-label="Search packages"
|
|
453
|
+
aria-keyshortcuts="/"
|
|
454
|
+
/>
|
|
455
|
+
{/* Decorative: the shortcut is announced by aria-keyshortcuts, so
|
|
456
|
+
repeating it here would be read twice. CSS hides it as soon as
|
|
457
|
+
the field is focused or holds a query. */}
|
|
458
|
+
<kbd class="search-hint" aria-hidden="true">/</kbd>
|
|
459
|
+
</div>
|
|
115
460
|
<div class="chips" role="group" aria-label="Sort by">
|
|
116
461
|
<button
|
|
117
462
|
type="button"
|
|
118
463
|
class={sort === "name" ? "chip active" : "chip"}
|
|
464
|
+
tabIndex={chipTabIndex}
|
|
465
|
+
onKeyDown={onChipKeyDown}
|
|
119
466
|
onClick={() => setSort("name")}
|
|
120
467
|
>
|
|
121
468
|
name
|
|
@@ -123,52 +470,85 @@ export default function Catalog({
|
|
|
123
470
|
<button
|
|
124
471
|
type="button"
|
|
125
472
|
class={sort === "updated" ? "chip active" : "chip"}
|
|
473
|
+
tabIndex={chipTabIndex}
|
|
474
|
+
onKeyDown={onChipKeyDown}
|
|
126
475
|
onClick={() => setSort("updated")}
|
|
127
476
|
>
|
|
128
477
|
updated
|
|
129
478
|
</button>
|
|
130
479
|
</div>
|
|
480
|
+
{/* Divides sort from filter — two different questions sharing a row.
|
|
481
|
+
Decorative only: each group already carries its own aria-label,
|
|
482
|
+
so this is hidden rather than announced. */}
|
|
483
|
+
<span class="chip-sep" aria-hidden="true"></span>
|
|
131
484
|
<div class="chips" role="group" aria-label="Filter by kind">
|
|
132
485
|
<button
|
|
133
486
|
type="button"
|
|
134
487
|
class={kind === null ? "chip active" : "chip"}
|
|
488
|
+
tabIndex={chipTabIndex}
|
|
489
|
+
onKeyDown={onChipKeyDown}
|
|
135
490
|
onClick={() => setKind(null)}
|
|
136
491
|
>
|
|
137
|
-
all <small>{
|
|
492
|
+
all <small>{counted.length}</small>
|
|
138
493
|
</button>
|
|
139
494
|
{kinds.map(([k, count]) => (
|
|
140
495
|
<button
|
|
141
496
|
key={k}
|
|
142
497
|
type="button"
|
|
143
498
|
class={kind === k ? `chip active kind-${k}` : `chip kind-${k}`}
|
|
499
|
+
tabIndex={chipTabIndex}
|
|
500
|
+
onKeyDown={onChipKeyDown}
|
|
144
501
|
onClick={() => setKind(kind === k ? null : k)}
|
|
145
502
|
>
|
|
146
503
|
{k} <small>{count}</small>
|
|
147
504
|
</button>
|
|
148
505
|
))}
|
|
149
506
|
</div>
|
|
507
|
+
{hasDeprecated && (
|
|
508
|
+
// A toggle, not a filter: `aria-pressed` rather than the `active`
|
|
509
|
+
// class alone, so it is announced as on/off instead of selected.
|
|
510
|
+
//
|
|
511
|
+
// No count, unlike the kind chips. Theirs is a fixed property of
|
|
512
|
+
// the catalog; this one would be the number currently hidden, which
|
|
513
|
+
// is zero once the toggle is on — so it vanished exactly when
|
|
514
|
+
// pressed and the chip changed width under the pointer.
|
|
515
|
+
<button
|
|
516
|
+
type="button"
|
|
517
|
+
class={showDeprecated ? "chip deprecated-toggle active" : "chip deprecated-toggle"}
|
|
518
|
+
aria-pressed={showDeprecated}
|
|
519
|
+
title={showDeprecated ? "Hide deprecated packages" : "Show deprecated packages"}
|
|
520
|
+
tabIndex={chipTabIndex}
|
|
521
|
+
onKeyDown={onChipKeyDown}
|
|
522
|
+
onClick={() => setShowDeprecated((on) => !on)}
|
|
523
|
+
>
|
|
524
|
+
deprecated
|
|
525
|
+
</button>
|
|
526
|
+
)}
|
|
150
527
|
</div>
|
|
151
528
|
|
|
152
529
|
{shown.length === 0 ? (
|
|
153
530
|
<p class="empty">No packages match.</p>
|
|
154
531
|
) : (
|
|
155
|
-
<ul class="grid">
|
|
532
|
+
<ul class="grid" ref={gridRef}>
|
|
156
533
|
{shown.map((p) => (
|
|
157
|
-
|
|
534
|
+
// One Tab stop per card, in DOM order — which the grid lays out
|
|
535
|
+
// left to right, top to bottom. Every control inside is taken
|
|
536
|
+
// out of the sequence (`tabindex={-1}`) so tabbing crosses the
|
|
537
|
+
// catalog instead of wading through it; arrow keys move by row
|
|
538
|
+
// and column, and Enter opens the detail page, which carries the
|
|
539
|
+
// same install commands the card's buttons do.
|
|
540
|
+
<li
|
|
541
|
+
key={`${p.namespace}/${p.name}`}
|
|
542
|
+
class="card"
|
|
543
|
+
tabIndex={0}
|
|
544
|
+
onKeyDown={onCardKeyDown}
|
|
545
|
+
>
|
|
158
546
|
<div class="card-head">
|
|
159
|
-
{p
|
|
160
|
-
<img class="card-logo" src={withBase(p.logo)} alt="" loading="lazy" />
|
|
161
|
-
) : (
|
|
162
|
-
<span
|
|
163
|
-
class="card-logo card-logo-fallback"
|
|
164
|
-
aria-hidden="true"
|
|
165
|
-
style={{ background: `var(--kind-${p.kind}, var(--muted))` }}
|
|
166
|
-
>
|
|
167
|
-
{p.name[0]?.toUpperCase()}
|
|
168
|
-
</span>
|
|
169
|
-
)}
|
|
547
|
+
<CardLogo pkg={p} />
|
|
170
548
|
<h2>
|
|
171
|
-
<a href={withBase(`/p/${p.namespace}/${p.name}/`)}
|
|
549
|
+
<a href={withBase(`/p/${p.namespace}/${p.name}/`)} tabIndex={-1}>
|
|
550
|
+
{p.name}
|
|
551
|
+
</a>
|
|
172
552
|
</h2>
|
|
173
553
|
{p.deprecated ? (
|
|
174
554
|
<span class="badge deprecated">deprecated</span>
|
|
@@ -202,6 +582,7 @@ export default function Catalog({
|
|
|
202
582
|
key={kw}
|
|
203
583
|
type="button"
|
|
204
584
|
class="chip keyword"
|
|
585
|
+
tabIndex={-1}
|
|
205
586
|
onClick={() => setQuery(kw)}
|
|
206
587
|
>
|
|
207
588
|
{kw}
|
|
@@ -216,24 +597,24 @@ export default function Catalog({
|
|
|
216
597
|
)}
|
|
217
598
|
<div class="card-foot">
|
|
218
599
|
<div class="copy-group">
|
|
219
|
-
|
|
600
|
+
{/* Global first, matching the hero's scope picker — the two
|
|
601
|
+
are the same choice in two places, so they lead with the
|
|
602
|
+
same one. */}
|
|
220
603
|
<CopyButton
|
|
221
604
|
command={`grim add --global ${p.ref}`}
|
|
222
605
|
variant="global"
|
|
606
|
+
name={`global add for ${p.name}`}
|
|
223
607
|
/>
|
|
608
|
+
<CopyButton command={`grim add ${p.ref}`} name={`project add for ${p.name}`} />
|
|
224
609
|
{vscodeUrl(vscodeExtension, p.ref) && (
|
|
225
610
|
<a
|
|
226
611
|
class="copy vscode"
|
|
227
612
|
href={vscodeUrl(vscodeExtension, p.ref)!}
|
|
228
613
|
title="Open in VS Code"
|
|
229
614
|
aria-label={`Open ${p.name} in VS Code`}
|
|
615
|
+
tabIndex={-1}
|
|
230
616
|
>
|
|
231
|
-
<
|
|
232
|
-
<path
|
|
233
|
-
fill="currentColor"
|
|
234
|
-
d="M23.15 2.587L18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12l-3.573 3.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z"
|
|
235
|
-
/>
|
|
236
|
-
</svg>
|
|
617
|
+
<BrandMark path={mdiMicrosoftVisualStudioCode} />
|
|
237
618
|
</a>
|
|
238
619
|
)}
|
|
239
620
|
</div>
|
|
@@ -243,6 +624,7 @@ export default function Catalog({
|
|
|
243
624
|
href={p.repository}
|
|
244
625
|
target="_blank"
|
|
245
626
|
rel="noopener noreferrer"
|
|
627
|
+
tabIndex={-1}
|
|
246
628
|
>
|
|
247
629
|
source
|
|
248
630
|
</a>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
// A command you copy by clicking it. The whole field is the button — there
|
|
3
|
+
// is no separate copy control, just an icon at the right edge that swaps to
|
|
4
|
+
// a green check on success. The click handler is the shared `[data-copy]`
|
|
5
|
+
// listener in Base.astro.
|
|
6
|
+
//
|
|
7
|
+
// The icons are Preact components with no client directive, so Astro renders
|
|
8
|
+
// them to static SVG at build time — no JavaScript ships for them.
|
|
9
|
+
import { Check, Copy } from "lucide-preact";
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
/** The command: shown, and copied verbatim. */
|
|
13
|
+
value: string;
|
|
14
|
+
/** Accessible name for the button — the visible text is the command. */
|
|
15
|
+
label: string;
|
|
16
|
+
/**
|
|
17
|
+
* What the copy toast calls this, e.g. `"Linux install command"`. A bar
|
|
18
|
+
* with a picker rewrites it on every pick, alongside `data-copy`.
|
|
19
|
+
*/
|
|
20
|
+
name?: string;
|
|
21
|
+
}
|
|
22
|
+
const { value, label, name } = Astro.props;
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
<button
|
|
26
|
+
type="button"
|
|
27
|
+
class="cmd-field"
|
|
28
|
+
data-copy={value}
|
|
29
|
+
data-copy-name={name}
|
|
30
|
+
aria-label={label}
|
|
31
|
+
>
|
|
32
|
+
<code>{value}</code>
|
|
33
|
+
{/* Both icons occupy one grid cell and cross-fade, so nothing reflows and
|
|
34
|
+
the check lands exactly where the copy glyph was. */}
|
|
35
|
+
<span class="cmd-icons" aria-hidden="true">
|
|
36
|
+
<Copy class="icon-idle" size={14} />
|
|
37
|
+
<Check class="icon-done" size={14} />
|
|
38
|
+
</span>
|
|
39
|
+
{/* The cross-fade is the only confirmation a sighted user gets;
|
|
40
|
+
`role="status"` makes it an implicit live region so the click is
|
|
41
|
+
announced too. */}
|
|
42
|
+
<span class="sr-only" role="status"></span>
|
|
43
|
+
</button>
|