SearchModal.astro (29082B)
1 --- 2 import Icon from './Icon.astro'; 3 --- 4 <div class="search-overlay" data-search-overlay hidden> 5 <div class="search-box plate corners" role="dialog" aria-modal="true" aria-label="Search DÆMONBins"> 6 <div class="search-titlebar"> 7 <span class="search-titlebar__label">Search — DÆMONBins</span> 8 <span class="search-titlebar__hints"><kbd>Esc</kbd> close · <kbd>⌘K</kbd> toggle</span> 9 </div> 10 <div class="search-inputrow"> 11 <Icon name="search" class="search-ico" /> 12 <input 13 type="search" class="search-input" data-search-input 14 placeholder="Search tools, techniques, MITRE ids…" autocomplete="off" 15 spellcheck="false" aria-label="Search query" 16 role="combobox" aria-expanded="false" aria-controls="search-results" 17 aria-autocomplete="list" /> 18 <button class="icon-btn" data-search-close type="button" aria-label="Close search"> 19 <Icon name="close" /> 20 </button> 21 </div> 22 <p class="search-status mono" data-search-status aria-live="polite" hidden></p> 23 <div 24 class="search-results" data-search-results id="search-results" 25 aria-label="Search results"> 26 <p class="search-hint mono"> 27 Type to search the catalog. <kbd>↑</kbd><kbd>↓</kbd> move, <kbd>↵</kbd> open, <kbd>Esc</kbd> close. 28 </p> 29 </div> 30 </div> 31 </div> 32 33 <style> 34 /* An opaque scrim rather than a frosted one. The page under it is as 35 often a dark band as it is cream, and a translucent overlay reads as 36 two different dialogs depending on where you happened to be. */ 37 .search-overlay { 38 position: fixed; inset: 0; z-index: 100; 39 display: grid; place-items: start center; 40 padding: clamp(3rem, 12vh, 8rem) 1rem 2rem; 41 background: color-mix(in oklab, var(--base) 88%, transparent); 42 } 43 .search-overlay[hidden] { display: none; } 44 .search-box { 45 /* `.corners` draws its brackets against the box's hairline from 46 pseudo-elements, so the box has to be their containing block. */ 47 position: relative; 48 width: min(660px, 100%); 49 /* The modal is a terminal window: a dark plate in BOTH modes (the 50 `.plate` class pins the night palette for the subtree), framed by a 51 hairline rather than by full ink — the corner brackets are the 52 accent, the frame stays furniture. */ 53 border: 1px solid var(--rule-hi); 54 background: var(--base); 55 } 56 /* The window's titlebar: a JetBrains Mono micro label naming the pane, 57 kbd hints at the right, hairline under. `--font-term`, not 58 `--font-mono` — this is terminal chrome, and chrome speaks JetBrains 59 Mono everywhere else on the site (code captions, segbar segments). */ 60 .search-titlebar { 61 display: flex; align-items: center; justify-content: space-between; 62 gap: 1rem; 63 padding: 0.5rem 0.9rem; 64 border-bottom: 1px solid var(--rule); 65 font-family: var(--font-term); 66 font-size: var(--step-micro); 67 letter-spacing: var(--track-micro); 68 text-transform: uppercase; 69 color: var(--fg-faint); 70 } 71 .search-titlebar__label { color: var(--fg-dim); } 72 .search-titlebar__hints { display: flex; align-items: center; gap: 0.35rem; white-space: nowrap; } 73 .search-titlebar kbd { 74 border: 1px solid var(--rule); 75 padding: 0.06rem 0.3rem; 76 font-family: var(--font-term); 77 color: var(--fg-faint); 78 } 79 /* Cross-theme in the other direction: on the night page the titlebar is 80 the modal's dawn plate — a cream strip floating on the dark window. 81 `.plate-dawn` is a token pin that applies in both modes, and here the 82 strip must appear only on the night page (on cream the modal is 83 already the cross-theme element), so the row re-pins the handful of 84 Dawn tokens it actually reads, scoped to the dark theme. */ 85 :root[data-theme='dark'] .search-titlebar { 86 --fg: #575279; --fg-dim: #635f7c; --fg-faint: #6e6a86; 87 --rule: rgba(87, 82, 121, 0.22); --rule-hi: rgba(87, 82, 121, 0.48); 88 background: #faf4ed; 89 color: var(--fg-faint); 90 } 91 .search-inputrow { display: flex; align-items: center; gap: 0.6rem; padding: 0.7rem 0.8rem; border-bottom: 1px solid var(--rule); } 92 /* .search-ico is sized in the is:global block below — it is the root 93 <svg> of the Icon child component, which never receives this file's 94 scope attribute, so a scoped rule here silently never applies and the 95 icon renders at the SVG default replaced size (a huge magnifying 96 glass). Same reason the result rows are styled globally. */ 97 .search-input { 98 flex: 1; background: none; border: none; outline: none; 99 color: var(--fg); font-size: 1.02rem; font-family: var(--font-mono); 100 } 101 .search-input::placeholder { color: var(--fg-faint); } 102 /* The count bar. The site labels everything in 10px mono; a result set 103 is no different, and knowing 8 of 47 are shown is the difference 104 between "that's all there is" and "keep typing". */ 105 .search-status { 106 display: flex; justify-content: space-between; gap: 1rem; 107 padding: 0.5rem 0.9rem; 108 border-bottom: 1px solid var(--rule); 109 color: var(--fg-faint); font-size: var(--step-micro); 110 letter-spacing: var(--track-micro); text-transform: uppercase; 111 } 112 .search-status[hidden] { display: none; } 113 114 .search-results { max-height: min(56vh, 520px); overflow-y: auto; overscroll-behavior: contain; } 115 </style> 116 117 <!-- Everything below draws nodes this component writes with `innerHTML` at 118 runtime — the result rows and the hint that replaces them. 119 120 Astro scopes a component's CSS by rewriting each selector to carry a 121 `[data-astro-cid-…]` attribute and stamping that attribute onto the 122 markup *at build time*. A node created later by script never receives 123 it, so a scoped rule silently does not apply: no error, no warning, and 124 a results list that renders as raw stacked text with no padding, no 125 rules and no wipe. That is what was wrong with this panel — the styles 126 had simply never reached it. 127 128 `is:global` is the escape hatch, and every rule is confined to 129 `.search-overlay` so nothing here can reach the page behind it. --> 130 <style is:global> 131 /* The leading magnifying glass. Sized here, not in the scoped block: it 132 is the Icon child's root <svg>, which carries Icon's scope id and not 133 this component's, so `.search-ico { … }` up in the scoped styles never 134 matched it and the icon fell back to the SVG default size. */ 135 .search-overlay .search-ico { width: 18px; height: 18px; color: var(--fg-faint); flex: 0 0 auto; } 136 137 .search-overlay .search-hint { 138 color: var(--fg-faint); font-size: var(--step-micro); 139 letter-spacing: var(--track-micro); text-transform: uppercase; 140 padding: 1rem 0.9rem; line-height: 2; 141 } 142 .search-overlay .search-hint kbd { 143 border: 1px solid var(--rule); padding: 0.06rem 0.3rem; 144 font-family: var(--font-mono); margin-right: 0.15rem; 145 } 146 .search-overlay .search-hint code { 147 font-family: var(--font-mono); text-transform: none; 148 } 149 150 /* A hit is a record, not a card. 151 152 The index rows on every other page of this site are one line — rank, 153 kind, title, chevron — because a list is read by its shape and a row 154 that grows to fit its content destroys the shape. This panel used to 155 stack three free-height lines per hit, so eight results ran from 100px 156 to 204px tall and only three and a half of them fitted the box. Fixing 157 the height is what makes the list scannable; everything else here is 158 the same furniture the vault indexes are built from. */ 159 .search-overlay .s-result { 160 display: grid; 161 grid-template-columns: 26px minmax(0, 1fr) 14px; 162 align-items: center; 163 column-gap: 0.7rem; 164 row-gap: 0.1rem; 165 padding: 0.62rem 0.9rem; 166 border-bottom: 1px solid var(--rule); 167 color: var(--fg); 168 background-image: linear-gradient(90deg, var(--fg) 0 50%, transparent 50% 100%); 169 background-size: 200% 100%; 170 background-position: 100% 0; 171 background-repeat: no-repeat; 172 transition: background-position 380ms var(--ease-in), color 1ms; 173 } 174 @media (min-width: 560px) { 175 .search-overlay .s-result { grid-template-columns: 26px 54px minmax(0, 1fr) 14px; } 176 } 177 /* Hover and keyboard selection are the same state deliberately: the 178 pointer and the arrow keys are moving one cursor, not two. */ 179 .search-overlay .s-result:hover, 180 .search-overlay .s-result[data-sel] { background-position: 0 0; color: var(--base); } 181 .search-overlay .s-result:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } 182 183 /* Rank, and the keyboard address — ↑/↓ move this number, ↵ opens it. */ 184 .search-overlay .s-n { 185 grid-column: 1; grid-row: 1; 186 font-family: var(--font-mono); font-size: var(--step-micro); 187 letter-spacing: var(--track-micro); color: var(--fg-faint); 188 } 189 /* The domain's own accent, from taxonomy.ts — the same legend the 190 masthead and the domain plates key back to. */ 191 .search-overlay .s-kind { 192 grid-column: 2; grid-row: 2; 193 font-family: var(--font-mono); font-size: var(--step-micro); 194 letter-spacing: var(--track-micro); text-transform: uppercase; 195 color: var(--acc, var(--accent)); 196 white-space: nowrap; overflow: hidden; text-overflow: ellipsis; 197 } 198 @media (min-width: 560px) { .search-overlay .s-kind { grid-row: 1; } } 199 .search-overlay .s-title { 200 grid-column: 2; grid-row: 1; 201 font-family: var(--font-display); font-weight: 600; 202 font-size: 0.98rem; letter-spacing: -0.015em; 203 min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 204 } 205 @media (min-width: 560px) { .search-overlay .s-title { grid-column: 3; } } 206 .search-overlay .s-snip { 207 grid-column: 2; grid-row: 3; 208 color: var(--fg-dim); font-size: 0.8rem; line-height: 1.35; 209 min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 210 } 211 @media (min-width: 560px) { .search-overlay .s-snip { grid-column: 3; grid-row: 2; } } 212 .search-overlay .s-go { 213 grid-column: 3; grid-row: 1; 214 color: var(--fg-faint); 215 transition: transform 250ms var(--ease); 216 } 217 @media (min-width: 560px) { .search-overlay .s-go { grid-column: 4; } } 218 .search-overlay .s-go svg { width: 14px; height: 14px; display: block; } 219 220 /* On the wipe's ink the accents sit near 1:1, so every coloured cell 221 drops its hue and takes the page ground at the /78 the record rows 222 use. Mixed from `--base` rather than written as the cream literal the 223 rest of the site uses for this: the wipe fills with `--fg`, which is 224 dark ink on the dawn theme but near-white on the night one, so a fixed 225 cream is correct in one mode and invisible in the other. */ 226 .search-overlay .s-result:hover .s-n, .search-overlay .s-result[data-sel] .s-n, 227 .search-overlay .s-result:hover .s-kind, .search-overlay .s-result[data-sel] .s-kind, 228 .search-overlay .s-result:hover .s-snip, .search-overlay .s-result[data-sel] .s-snip, 229 .search-overlay .s-result:hover .s-go, .search-overlay .s-result[data-sel] .s-go { 230 color: color-mix(in oklab, var(--base) 78%, transparent); 231 } 232 .search-overlay .s-result:hover .s-go, 233 .search-overlay .s-result[data-sel] .s-go { transform: translateX(3px); } 234 235 /* The match itself has to survive the wipe, so it gets a second 236 treatment on ink rather than keeping a gold wash that goes muddy. */ 237 .search-overlay .s-snip mark { 238 background: color-mix(in oklab, var(--gold) 34%, transparent); 239 color: inherit; padding: 0 2px; 240 } 241 .search-overlay .s-result:hover .s-snip mark, 242 .search-overlay .s-result[data-sel] .s-snip mark { 243 background: color-mix(in oklab, var(--base) 22%, transparent); 244 color: var(--base); 245 } 246 247 @media (prefers-reduced-motion: reduce) { 248 .search-overlay .s-result, .search-overlay .s-go { transition: none; } 249 } 250 </style> 251 252 <script> 253 import { SOURCE_META } from '../lib/taxonomy'; 254 255 const BASE = import.meta.env.BASE_URL; 256 257 /* Looked up per call, never held. 258 259 `ClientRouter` swaps the whole body on an internal navigation, so the 260 modal on screen after the first click-through is a different element 261 from the one that was in the document when this module ran. Module 262 scripts are executed once per session — Astro replaces an already-run 263 inline script with an empty `data:` URI on swap — so a reference 264 captured up here is never refreshed, and every later `open()` was 265 unhiding a detached node while locking scroll on the live body: 266 nothing appeared and the page could no longer be scrolled. 267 268 Re-querying at the point of use costs one `querySelector` per 269 interaction and cannot go stale. */ 270 const overlayEl = () => document.querySelector<HTMLElement>('[data-search-overlay]'); 271 const inputEl = () => document.querySelector<HTMLInputElement>('[data-search-input]'); 272 const resultsEl = () => document.querySelector<HTMLElement>('[data-search-results]'); 273 274 const statusEl = () => document.querySelector<HTMLElement>('[data-search-status]'); 275 276 const HINT = 277 '<p class="search-hint mono">Type to search the catalog. ' + 278 '<kbd>↑</kbd><kbd>↓</kbd> move, <kbd>↵</kbd> open, <kbd>Esc</kbd> close.</p>'; 279 280 /* Pagefind reports the category as its display title, which is all a 281 human needs but not what the row is drawn from: the vault's rows carry 282 a short module tag in the domain's own accent. Both come from 283 `taxonomy.ts` rather than a second copy of the table here, so adding a 284 domain there gives search the right badge and the right colour with no 285 further edit. Payloads is not a domain in that list — it is the mirror 286 — and takes the iris the masthead already gives it. */ 287 const KIND = new Map<string, { tag: string; accent: string }>( 288 Object.values(SOURCE_META).map((s) => [s.label, { tag: s.tag, accent: s.accent }] as const), 289 ); 290 const kindOf = (source?: string) => 291 (source && KIND.get(source)) || { tag: source || 'TECH', accent: 'foam' }; 292 293 /* The index itself does survive navigation, and should — it is a few 294 hundred KB of WASM and index shards, and re-fetching it per page would 295 undo the point of a client-side router. */ 296 let pagefind: any = null; 297 let loading: Promise<any> | null = null; 298 299 /* Native dynamic import, hidden from Vite. 300 301 Pagefind ships as a prebuilt ES module with its own WASM and code-split 302 chunks. A literal `import(path)` is rewritten by Vite's dev server: it 303 appends `?import` and tries to push pagefind's bundle through its own 304 transform pipeline, which 500s — so search worked under `astro preview` 305 (no Vite) but never under `astro dev`. `@vite-ignore` doesn't help; Vite 306 still rewrites the specifier. 307 308 Building the import through `new Function` means the token `import(` is 309 never in source Vite can scan, so it emits a genuine browser-native 310 import that fetches pagefind untouched. Pagefind's own internal chunk 311 imports then resolve against its real URL and are served straight from 312 `public/pagefind/` (or `dist/pagefind/` in prod). 313 314 Dev only. `new Function` is an eval, and the site's Content Security 315 Policy (public/_headers) allows no eval, so in the build the import is 316 written plainly: Rollup leaves a runtime-string `import()` native, and 317 `import.meta.env.DEV` is a literal there, so the eval branch is not in 318 the bundle at all. */ 319 const nativeImport: (u: string) => Promise<any> = import.meta.env.DEV 320 ? (new Function('u', 'return import(u)') as any) 321 : (u: string) => import(/* @vite-ignore */ u); 322 323 function loadPagefind() { 324 if (loading) return loading; 325 /* Join on a normalised base rather than concatenating onto it. 326 `BASE_URL` is `/daemon-sec-cheatsheet` — no trailing slash, because 327 the config sets `trailingSlash: 'ignore'` — so the old 328 `${BASE}pagefind/…` produced `/daemon-sec-cheatsheetpagefind/…` and 329 404'd on every environment including production. Stripping any 330 trailing slashes first makes the join correct whether or not the 331 base carries one. */ 332 const path = `${BASE.replace(/\/+$/, '')}/pagefind/pagefind.js`; 333 loading = nativeImport(path) 334 .then(async (mod: any) => { await mod.init?.(); pagefind = mod; return mod; }) 335 .catch(() => { pagefind = false; return null; }); 336 return loading; 337 } 338 339 /* The element that opened the palette, so Esc / close hands focus back 340 to it instead of dropping it on <body>. */ 341 let opener: HTMLElement | null = null; 342 343 function open() { 344 const overlay = overlayEl(); 345 if (!overlay) return; 346 opener = document.activeElement instanceof HTMLElement ? document.activeElement : null; 347 overlay.hidden = false; 348 document.body.style.overflow = 'hidden'; 349 loadPagefind(); 350 setTimeout(() => inputEl()?.focus(), 20); 351 } 352 function close() { 353 const overlay = overlayEl(); 354 document.body.style.overflow = ''; 355 if (!overlay) return; 356 overlay.hidden = true; 357 const input = inputEl(); 358 if (input) input.value = ''; 359 write(HINT); 360 if (opener && opener.isConnected) opener.focus(); 361 opener = null; 362 } 363 364 /* Keep Tab inside the dialog while it is open: the page behind the 365 scrim is inert to the eye but not to the keyboard. */ 366 function trapTab(e: KeyboardEvent) { 367 const overlay = overlayEl(); 368 if (!overlay || overlay.hidden || e.key !== 'Tab') return; 369 const focusable = Array.from(overlay.querySelectorAll<HTMLElement>( 370 'input, button, a[href], [tabindex]:not([tabindex="-1"])', 371 )).filter((el) => !el.hidden && el.offsetParent !== null); 372 if (!focusable.length) return; 373 const first = focusable[0]; 374 const last = focusable[focusable.length - 1]; 375 const active = document.activeElement; 376 if (e.shiftKey && (active === first || !overlay.contains(active))) { e.preventDefault(); last.focus(); } 377 else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); } 378 } 379 380 function esc(s: string) { 381 return String(s).replace(/[&<>"']/g, (c) => 382 ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string)); 383 } 384 385 const CHEVRON = 386 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" ' + 387 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 6 6 6-6 6"/></svg>'; 388 389 /* ---- Excerpt repair ----------------------------------------------------- 390 Pagefind excerpts are the page's own prose with `<mark>` around the hit, 391 and the vault's prose is not prose: it is command listings, tables and 392 emoji-headed sections. Untouched, a snippet arrives looking like 393 394 "# ── Set SPN on target ──────────────────── Set-DomainObject -Identity" 395 396 which is not a sentence, is not a preview, and is what pushed one row to 397 204px while its neighbour sat at 100px. No amount of styling fixes text 398 that is mostly rule characters, so the string is repaired before it is 399 ever measured. 400 401 Everything below is tag-aware: `<mark>` is the whole point of an excerpt 402 and must survive, so nothing here counts or cuts raw HTML positions. */ 403 404 /** Drop the first `n` *visible* characters, stepping over tags. */ 405 function dropLeading(html: string, n: number) { 406 let out = ''; 407 let i = 0; 408 let dropped = 0; 409 while (i < html.length) { 410 if (html[i] === '<') { 411 const end = html.indexOf('>', i); 412 if (end === -1) break; 413 out += html.slice(i, end + 1); 414 i = end + 1; 415 continue; 416 } 417 if (dropped < n) dropped++; 418 else out += html[i]; 419 i++; 420 } 421 return out; 422 } 423 424 const plain = (html: string) => html.replace(/<[^>]*>/g, ''); 425 426 function tidyExcerpt(raw: string, title: string) { 427 let html = String(raw || ''); 428 429 // Pagefind only ever emits <mark>; anything else reaching the panel is 430 // content that escaped indexing, and it is not this component's job to 431 // render it. 432 html = html.replace(/<(?!\/?mark\s*\/?>)[^>]*>/gi, ''); 433 434 // Pictographs, box drawing, block elements, the long ASCII rules that 435 // head almost every command section in this vault, and the `:warning:` 436 // style shortcodes the PayloadsAllTheThings mirror carries in from 437 // GitHub-flavoured markdown. The shortcode pattern is deliberately 438 // anchored to whitespace on both sides so it cannot bite a `key:value:` 439 // pair out of a command line. 440 html = html 441 .replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE0F}\u{20E3}]/gu, ' ') 442 .replace(/[─-▟]+/g, ' ') 443 .replace(/(^|\s):[a-z][a-z0-9_+-]{2,}:(?=\s|$)/gi, '$1') 444 .replace(/([=_*~#·•+\-])\1{2,}/g, ' ') 445 .replace(/\s+/g, ' ') 446 .trim(); 447 448 // The heading is nearly always the first thing in the body text, so the 449 // snippet opens by repeating the title directly above it. Say it once. 450 const t = title.trim().toLowerCase(); 451 if (t && plain(html).toLowerCase().startsWith(t)) html = dropLeading(html, t.length); 452 453 /* Tidying the head of the string can leave the punctuation that used to 454 separate it from the title, and can empty a <mark> whose text was 455 part of the title — a marked-up heading is the common case, not the 456 exception. Both have to go, and each can uncover the other. */ 457 for (let pass = 0; pass < 3; pass++) { 458 const trimmed = html 459 .replace(/<mark[^>]*>\s*<\/mark>/gi, '') 460 .replace(/^[\s.,:;|—–-]+/, ''); 461 if (trimmed === html) break; 462 html = trimmed; 463 } 464 465 /* A one-line snippet is only worth showing if the match is on that line. 466 Pagefind usually centres the excerpt on the hit, but not always — when 467 the match starts late, wind the snippet forward so it lands early 468 enough to survive the ellipsis. */ 469 const markTag = html.search(/<mark/i); 470 if (markTag > 0) { 471 const visibleBefore = plain(html.slice(0, markTag)).length; 472 if (visibleBefore > 46) html = '… ' + dropLeading(html, visibleBefore - 24); 473 } 474 475 return html; 476 } 477 478 /* Writes go through here for the same reason reads do: a search started 479 on one page can resolve after a navigation has replaced the results 480 panel, and the answer belongs in whichever panel is on screen now. */ 481 function write(html: string, status?: string) { 482 const results = resultsEl(); 483 if (results) results.innerHTML = html; 484 const bar = statusEl(); 485 if (bar) { 486 bar.hidden = !status; 487 bar.innerHTML = status || ''; 488 } 489 /* A listbox may only contain options, so the container is only a 490 listbox while it holds results — the hint and the empty state are 491 prose and are announced as prose. */ 492 if (results) { 493 if (status) results.setAttribute('role', 'listbox'); 494 else results.removeAttribute('role'); 495 } 496 const input = inputEl(); 497 input?.setAttribute('aria-expanded', String(!!status)); 498 select(status ? 0 : -1); 499 } 500 501 /* ---- The cursor --------------------------------------------------------- 502 One cursor, moved by either device: `[data-sel]` takes the same ink wipe 503 hovering does, so the pointer and the arrow keys are visibly driving the 504 same thing rather than lighting two different rows. The top hit is 505 selected on arrival, which is what makes ↵ worth pressing. */ 506 let sel = -1; 507 function rows() { 508 return Array.from(document.querySelectorAll<HTMLAnchorElement>('.s-result')); 509 } 510 function select(i: number) { 511 const list = rows(); 512 list.forEach((r) => r.removeAttribute('data-sel')); 513 sel = list.length ? Math.max(0, Math.min(i, list.length - 1)) : -1; 514 const input = inputEl(); 515 if (sel < 0) { input?.removeAttribute('aria-activedescendant'); return; } 516 const row = list[sel]; 517 row.setAttribute('data-sel', ''); 518 row.setAttribute('aria-selected', 'true'); 519 list.forEach((r, n) => { if (n !== sel) r.setAttribute('aria-selected', 'false'); }); 520 input?.setAttribute('aria-activedescendant', row.id); 521 row.scrollIntoView({ block: 'nearest' }); 522 } 523 524 const RESULT_LIMIT = 8; 525 526 let seq = 0; 527 async function run(q: string) { 528 const mine = ++seq; 529 if (!q.trim()) { write(HINT); return; } 530 await loadPagefind(); 531 if (mine !== seq) return; 532 /* The index is generated by Pagefind after `astro build`, so it exists 533 in `dist/` and never under `astro dev`. This is the expected state on 534 the dev server — `npm run preview` serves the built site with it. */ 535 if (pagefind === false) { write('<p class="search-hint mono">Search index not loaded. It is built by <code>npm run build</code>; use <code>npm run preview</code> to search locally.</p>'); return; } 536 const search = await pagefind.search(q); 537 if (mine !== seq) return; 538 const total = search.results.length; 539 const data = await Promise.all( 540 search.results.slice(0, RESULT_LIMIT).map((r: any) => r.data()), 541 ); 542 if (mine !== seq) return; 543 if (!data.length) { 544 write(`<p class="search-hint mono">No matches for “${esc(q)}”. Try a tool name, a technique, or a CVE.</p>`); 545 return; 546 } 547 548 const html = data.map((d: any, i: number) => { 549 const title = String(d.meta?.title || d.url); 550 const kind = kindOf(d.meta?.source); 551 const snip = tidyExcerpt(d.excerpt, title); 552 return ( 553 `<a class="s-result" id="s-opt-${i}" role="option" aria-selected="false"` + 554 ` href="${esc(String(d.url).replace(/(.)\/$/, '$1'))}" style="--acc: var(--${kind.accent});">` + 555 `<span class="s-n">${String(i + 1).padStart(2, '0')}</span>` + 556 `<span class="s-kind">${esc(kind.tag)}</span>` + 557 `<span class="s-title">${esc(title)}</span>` + 558 `<span class="s-snip">${snip}</span>` + 559 `<span class="s-go">${CHEVRON}</span>` + 560 `</a>` 561 ); 562 }).join(''); 563 564 /* Say how much of the result set is on screen. "8 results" reads as the 565 whole answer when it is the first page of forty. */ 566 const shown = data.length; 567 const status = 568 `<span>${total === shown ? `${total} match${total === 1 ? '' : 'es'}` : `${shown} of ${total} matches`}</span>` + 569 `<span>↑↓ ↵</span>`; 570 write(html, status); 571 } 572 573 /* Delegated rather than bound to the field, so that it keeps working 574 against whichever field is in the document — `input` bubbles. */ 575 let t: any; 576 document.addEventListener('input', (e) => { 577 const el = e.target as HTMLElement; 578 if (!el?.matches?.('[data-search-input]')) return; 579 const value = (el as HTMLInputElement).value; 580 clearTimeout(t); 581 t = setTimeout(() => run(value), 120); 582 }); 583 584 document.addEventListener('click', (e) => { 585 const el = e.target as HTMLElement; 586 if (el.closest('[data-search-open]')) { e.preventDefault(); open(); } 587 else if (el.closest('[data-search-close]')) close(); 588 else if (el === overlayEl()) close(); 589 }); 590 document.addEventListener('keydown', (e) => { 591 const overlay = overlayEl(); 592 const isOpen = !!overlay && !overlay.hidden; 593 594 /* Arrow keys drive the list while the field keeps the caret, which is 595 the whole reason a palette is quicker than a results page. Enter 596 follows the selection rather than submitting the form. */ 597 if (isOpen && (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Home' || e.key === 'End')) { 598 const list = rows(); 599 if (!list.length) return; 600 e.preventDefault(); 601 if (e.key === 'ArrowDown') select(sel < 0 ? 0 : (sel + 1) % list.length); 602 else if (e.key === 'ArrowUp') select(sel <= 0 ? list.length - 1 : sel - 1); 603 else if (e.key === 'Home') select(0); 604 else select(list.length - 1); 605 return; 606 } 607 if (isOpen && e.key === 'Enter') { 608 const row = rows()[sel]; 609 if (row) { e.preventDefault(); row.click(); } 610 return; 611 } 612 613 if (isOpen && e.key === 'Tab') { trapTab(e); return; } 614 if (e.key === 'Escape' && isOpen) close(); 615 else if (e.key === '/' && overlay?.hidden && !/^(INPUT|TEXTAREA|SELECT)$/.test((e.target as HTMLElement)?.tagName)) { e.preventDefault(); open(); } 616 else if ((e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey)) { e.preventDefault(); overlay?.hidden ? open() : close(); } 617 }); 618 619 /* Pointer and keyboard share one cursor, so moving the mouse over a row 620 moves the selection to it rather than lighting a second one. */ 621 document.addEventListener('pointermove', (e) => { 622 const row = (e.target as HTMLElement)?.closest?.('.s-result'); 623 if (!row) return; 624 const i = rows().indexOf(row as HTMLAnchorElement); 625 if (i >= 0 && i !== sel) select(i); 626 }); 627 628 /* Following a result navigates client-side, which swaps the modal away 629 without anything calling `close()`. The scroll lock lives on <body>, 630 so release it on the way out rather than leaving the next page 631 unscrollable. */ 632 document.addEventListener('astro:before-swap', () => { document.body.style.overflow = ''; }); 633 document.addEventListener('astro:after-swap', () => { document.body.style.overflow = ''; }); 634 </script>