mirror of https://github.com/sveltejs/svelte
- use search components - use shell componentpull/8453/head
parent
339ea85d55
commit
bad1780d48
@ -1,68 +0,0 @@
|
||||
/** @param {HTMLElement} node */
|
||||
export function focusable_children(node) {
|
||||
const nodes = Array.from(
|
||||
node.querySelectorAll(
|
||||
'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
);
|
||||
|
||||
const index = nodes.indexOf(document.activeElement);
|
||||
|
||||
const update = (d) => {
|
||||
let i = index + d;
|
||||
i += nodes.length;
|
||||
i %= nodes.length;
|
||||
|
||||
// @ts-expect-error
|
||||
nodes[i].focus();
|
||||
};
|
||||
|
||||
return {
|
||||
/** @param {string} [selector] */
|
||||
next: (selector) => {
|
||||
const reordered = [...nodes.slice(index + 1), ...nodes.slice(0, index + 1)];
|
||||
|
||||
for (let i = 0; i < reordered.length; i += 1) {
|
||||
if (!selector || reordered[i].matches(selector)) {
|
||||
reordered[i].focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
/** @param {string} [selector] */
|
||||
prev: (selector) => {
|
||||
const reordered = [...nodes.slice(index + 1), ...nodes.slice(0, index + 1)];
|
||||
|
||||
for (let i = reordered.length - 2; i >= 0; i -= 1) {
|
||||
if (!selector || reordered[i].matches(selector)) {
|
||||
reordered[i].focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
update
|
||||
};
|
||||
}
|
||||
|
||||
export function trap(node) {
|
||||
const handle_keydown = (e) => {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
|
||||
const group = focusable_children(node);
|
||||
if (e.shiftKey) {
|
||||
group.prev();
|
||||
} else {
|
||||
group.next();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
node.addEventListener('keydown', handle_keydown);
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
node.removeEventListener('keydown', handle_keydown);
|
||||
}
|
||||
};
|
||||
}
|
@ -1,130 +0,0 @@
|
||||
<script>
|
||||
import { browser } from '$app/environment';
|
||||
import { searching, query } from './stores.js';
|
||||
|
||||
export let q = '';
|
||||
</script>
|
||||
|
||||
<form class="search-container" action="/search">
|
||||
<input
|
||||
value={q}
|
||||
on:input={(e) => {
|
||||
$searching = true;
|
||||
$query = e.currentTarget.value;
|
||||
e.currentTarget.value = '';
|
||||
}}
|
||||
on:mousedown|preventDefault={() => ($searching = true)}
|
||||
on:touchend|preventDefault={() => ($searching = true)}
|
||||
type="search"
|
||||
name="q"
|
||||
placeholder="Search"
|
||||
aria-label="Search"
|
||||
spellcheck="false"
|
||||
/>
|
||||
|
||||
{#if browser}
|
||||
<div class="shortcut">
|
||||
<kbd>{navigator.platform === 'MacIntel' ? '⌘' : 'Ctrl'}</kbd> <kbd>K</kbd>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<style>
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.search-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 0.5em 0.5em 0.4em 2em;
|
||||
border: 1px solid var(--sk-back-translucent);
|
||||
font-family: inherit;
|
||||
font-size: 1.4rem;
|
||||
/* text-align: center; */
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
height: 3.2rem;
|
||||
border-radius: var(--sk-border-radius);
|
||||
background: no-repeat 1rem 50% / 1em 1em url(/icons/search.svg);
|
||||
color: var(--sk-text-3);
|
||||
}
|
||||
|
||||
input:focus + .shortcut {
|
||||
display: none;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
font-size: 1.2rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
color: var(--sk-text-3);
|
||||
position: absolute;
|
||||
top: calc(50% - 0.9rem);
|
||||
right: 0;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
pointer-events: none;
|
||||
font-size: 1.2rem;
|
||||
text-transform: uppercase;
|
||||
animation: fade-in 0.2s;
|
||||
}
|
||||
|
||||
kbd {
|
||||
display: none;
|
||||
background: var(--sk-back-2);
|
||||
border: 1px solid var(--sk-back-translucent);
|
||||
padding: 0.2rem 0.2rem 0rem 0.2rem;
|
||||
color: var(--sk-text-3);
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
.search-container {
|
||||
width: 11rem;
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
padding: 0 1.6rem 0 0;
|
||||
}
|
||||
|
||||
input {
|
||||
border-radius: 1.6rem;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* we're using media query as an imperfect proxy for mobile/desktop */
|
||||
kbd {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.search-container {
|
||||
width: 19rem;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,420 +0,0 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import Icon from '@sveltejs/site-kit/components/Icon.svelte';
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import { searching, query, recent } from './stores.js';
|
||||
import { focusable_children, trap } from '../actions/focus.js';
|
||||
import SearchResults from './SearchResults.svelte';
|
||||
import SearchWorker from '$lib/workers/search.js?worker';
|
||||
|
||||
let modal;
|
||||
|
||||
let search = null;
|
||||
let recent_searches = [];
|
||||
|
||||
let worker;
|
||||
let ready = false;
|
||||
|
||||
let uid = 1;
|
||||
const pending = new Set();
|
||||
|
||||
onMount(async () => {
|
||||
worker = new SearchWorker();
|
||||
|
||||
worker.addEventListener('message', (event) => {
|
||||
const { type, payload } = event.data;
|
||||
|
||||
if (type === 'ready') {
|
||||
ready = true;
|
||||
}
|
||||
|
||||
if (type === 'results') {
|
||||
search = payload;
|
||||
}
|
||||
|
||||
if (type === 'recents') {
|
||||
recent_searches = payload;
|
||||
}
|
||||
});
|
||||
|
||||
worker.postMessage({
|
||||
type: 'init',
|
||||
payload: {
|
||||
origin: location.origin,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterNavigate(() => {
|
||||
// TODO this also needs to apply when only the hash changes
|
||||
// (should before/afterNavigate fire at that time? unclear)
|
||||
close();
|
||||
});
|
||||
|
||||
function close() {
|
||||
if ($searching) {
|
||||
$searching = false;
|
||||
const scroll = -parseInt(document.body.style.top || '0');
|
||||
document.body.style.position = '';
|
||||
document.body.style.top = '';
|
||||
document.body.tabIndex = -1;
|
||||
document.body.focus();
|
||||
document.body.removeAttribute('tabindex');
|
||||
window.scrollTo(0, scroll);
|
||||
}
|
||||
|
||||
search = null;
|
||||
}
|
||||
|
||||
/** @param {string} href */
|
||||
function navigate(href) {
|
||||
$recent = [href, ...$recent.filter((x) => x !== href)];
|
||||
close();
|
||||
}
|
||||
|
||||
$: if (ready) {
|
||||
const id = uid++;
|
||||
pending.add(id);
|
||||
|
||||
worker.postMessage({ type: 'query', id, payload: $query });
|
||||
}
|
||||
|
||||
$: if (ready) {
|
||||
worker.postMessage({ type: 'recents', payload: $recent });
|
||||
}
|
||||
|
||||
$: if ($searching) {
|
||||
document.body.style.top = `-${window.scrollY}px`;
|
||||
document.body.style.position = 'fixed';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'k' && (navigator.platform === 'MacIntel' ? e.metaKey : e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
$query = '';
|
||||
|
||||
if ($searching) {
|
||||
close();
|
||||
} else {
|
||||
$searching = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.code === 'Escape') {
|
||||
close();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if $searching && ready}
|
||||
<div class="modal-background" on:click={close} on:keyup={(e) => e.key === ' ' && close()} />
|
||||
|
||||
<div
|
||||
bind:this={modal}
|
||||
class="modal"
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const group = focusable_children(e.currentTarget);
|
||||
|
||||
// when using arrow keys (as opposed to tab), don't focus buttons
|
||||
const selector = 'a, input';
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
group.next(selector);
|
||||
} else {
|
||||
group.prev(selector);
|
||||
}
|
||||
}
|
||||
}}
|
||||
use:trap
|
||||
>
|
||||
<div class="search-box">
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input
|
||||
autofocus
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
modal.querySelector('a[data-has-node]')?.click();
|
||||
}
|
||||
}}
|
||||
on:input={(e) => ($query = e.currentTarget.value)}
|
||||
value={$query}
|
||||
placeholder="Search"
|
||||
aria-describedby="search-description"
|
||||
aria-label="Search"
|
||||
spellcheck="false"
|
||||
/>
|
||||
|
||||
<button aria-label="Close" on:click={close}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
|
||||
<span id="search-description" class="visually-hidden">Results will update as you type</span>
|
||||
|
||||
<div class="results">
|
||||
{#if search?.query}
|
||||
<div
|
||||
class="results-container"
|
||||
on:click={() => ($searching = false)}
|
||||
on:keydown={(e) => e.key === ' ' && ($searching = false)}
|
||||
>
|
||||
<SearchResults
|
||||
results={search.results}
|
||||
query={search.query}
|
||||
on:select={(e) => {
|
||||
navigate(e.detail.href);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<h2 class="info" class:empty={recent_searches.length === 0}>
|
||||
{recent_searches.length ? 'Recent searches' : 'No recent searches'}
|
||||
</h2>
|
||||
{#if recent_searches.length}
|
||||
<div class="results-container">
|
||||
<ul>
|
||||
{#each recent_searches as search, i}
|
||||
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
|
||||
<li class="recent">
|
||||
<a on:click={() => navigate(search.href)} href={search.href}>
|
||||
<small>{search.breadcrumbs.join('/')}</small>
|
||||
<strong>{search.breadcrumbs.at(-1)}</strong>
|
||||
</a>
|
||||
|
||||
<button
|
||||
aria-label="Delete"
|
||||
on:click={(e) => {
|
||||
$recent = $recent.filter((href) => href !== search.href);
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Icon name="delete" />
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div aria-live="assertive" class="visually-hidden">
|
||||
{#if $searching && search?.results.length === 0}
|
||||
<p>No results</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
input {
|
||||
font-family: inherit;
|
||||
font-size: 1.6rem;
|
||||
width: 100%;
|
||||
padding: 1rem 6rem 0.5rem 1rem;
|
||||
height: 5rem;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--sk-back-3);
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
background: var(--sk-back-2);
|
||||
color: var(--sk-text-1);
|
||||
}
|
||||
|
||||
input::selection {
|
||||
background-color: var(--sk-back-translucent);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--sk-text-3);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
input:focus-visible {
|
||||
background: var(--sk-theme-2);
|
||||
color: white;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus-visible::placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
button[aria-label='Close'] {
|
||||
--size: 2rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
background: none;
|
||||
color: var(--sk-text-2);
|
||||
}
|
||||
|
||||
button[aria-label='Close']:focus-visible {
|
||||
background: var(--sk-theme-2);
|
||||
color: var(--sk-back-1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus-visible + button[aria-label='Close'] {
|
||||
color: var(--sk-back-1);
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-background,
|
||||
.modal {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.modal-background {
|
||||
background: var(--sk-back-1);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
position: relative;
|
||||
height: calc(100% - 2rem);
|
||||
width: calc(100vw - 2rem);
|
||||
max-width: 50rem;
|
||||
max-height: 50rem;
|
||||
filter: drop-shadow(2px 4px 16px rgba(0, 0, 0, 0.2));
|
||||
border-radius: var(--sk-border-radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-box > * {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.results {
|
||||
overflow: auto;
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
.results-container {
|
||||
background: var(--sk-back-2);
|
||||
border-radius: 0 0 var(--sk-border-radius) var(--sk-border-radius);
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.info {
|
||||
padding: 1rem;
|
||||
font-size: 1.2rem;
|
||||
font-weight: normal;
|
||||
text-transform: uppercase;
|
||||
background-color: var(--sk-back-2);
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.info.empty {
|
||||
border-radius: 0 0 var(--sk-border-radius) var(--sk-border-radius);
|
||||
}
|
||||
|
||||
a {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
line-height: 1;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
a:focus {
|
||||
background: var(--sk-theme-2);
|
||||
color: var(--sk-back-1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
a small,
|
||||
a strong {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
a small {
|
||||
font-size: 1rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
color: var(--sk-text-3);
|
||||
}
|
||||
|
||||
a strong {
|
||||
font-size: 1.6rem;
|
||||
color: var(--sk-text-2);
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
|
||||
a:focus small {
|
||||
color: white;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
a:focus strong {
|
||||
color: white;
|
||||
}
|
||||
|
||||
a strong :global(mark) {
|
||||
background: var(--sk-theme-2);
|
||||
color: var(--sk-text-3);
|
||||
text-decoration: none;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
button[aria-label='Delete'] {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 5rem;
|
||||
height: 100%;
|
||||
color: var(--sk-text-2);
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
a:focus + [aria-label='Delete'] {
|
||||
color: var(--sk-back-1);
|
||||
}
|
||||
|
||||
button[aria-label='Delete']:hover {
|
||||
opacity: 1;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
button[aria-label='Delete']:focus-visible {
|
||||
background: var(--sk-theme-2);
|
||||
color: var(--sk-text-1);
|
||||
opacity: 1;
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
@ -1,163 +0,0 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
/** @type {import('./types').Tree[]} */
|
||||
export let results;
|
||||
|
||||
/** @type {string} */
|
||||
export let query;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
/** @param {string} text */
|
||||
function escape(text) {
|
||||
return text.replace(/</g, '<').replace(/>/g, '>').replaceAll('`', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} content
|
||||
* @param {string} query
|
||||
*/
|
||||
function excerpt(content, query) {
|
||||
if (content === null) return '';
|
||||
|
||||
const index = content.toLowerCase().indexOf(query.toLowerCase());
|
||||
if (index === -1) {
|
||||
return escape(content.slice(0, 100));
|
||||
}
|
||||
|
||||
const prefix = index > 20 ? `…${content.slice(index - 15, index)}` : content.slice(0, index);
|
||||
const suffix = content.slice(
|
||||
index + query.length,
|
||||
index + query.length + (80 - (prefix.length + query.length))
|
||||
);
|
||||
|
||||
return (
|
||||
escape(prefix) +
|
||||
`<mark>${escape(content.slice(index, index + query.length))}</mark>` +
|
||||
escape(suffix)
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<ul>
|
||||
{#each results as result (result.href)}
|
||||
<li>
|
||||
<a
|
||||
data-sveltekit-preload-data
|
||||
href={result.href}
|
||||
on:click={() => dispatch('select', { href: result.href })}
|
||||
data-has-node={result.node ? true : undefined}
|
||||
>
|
||||
<strong>{@html excerpt(result.breadcrumbs[result.breadcrumbs.length - 1], query)}</strong>
|
||||
|
||||
{#if result.node?.content}
|
||||
<span>{@html excerpt(result.node.content, query)}</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
{#if result.children.length > 0}
|
||||
<svelte:self results={result.children} {query} on:select />
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
ul {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ul :global(ul) {
|
||||
margin-left: 0.8em !important;
|
||||
padding-left: 0em;
|
||||
border-left: 1px solid var(--sk-back-5);
|
||||
}
|
||||
|
||||
li {
|
||||
list-style: none;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
ul ul li {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
line-height: 1;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
a:focus {
|
||||
background: var(--sk-theme-2);
|
||||
color: white;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
a strong,
|
||||
a span {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
a strong {
|
||||
font-size: 1.6rem;
|
||||
color: var(--sk-text-2);
|
||||
}
|
||||
|
||||
a span {
|
||||
font-size: 1.2rem;
|
||||
color: #737373;
|
||||
margin: 0.4rem 0 0 0;
|
||||
}
|
||||
|
||||
a :global(mark) {
|
||||
--highlight-color: rgba(255, 255, 0, 0.2);
|
||||
}
|
||||
|
||||
a span :global(mark) {
|
||||
background: none;
|
||||
color: var(--sk-text-1);
|
||||
background: var(--highlight-color);
|
||||
outline: 2px solid var(--highlight-color);
|
||||
border-top: 2px solid var(--highlight-color);
|
||||
/* mix-blend-mode: darken; */
|
||||
}
|
||||
|
||||
a:focus span {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
a:focus strong {
|
||||
color: white;
|
||||
}
|
||||
|
||||
a:focus span :global(mark),
|
||||
a:focus strong :global(mark) {
|
||||
--highlight-color: hsl(240, 8%, 54%);
|
||||
mix-blend-mode: lighten;
|
||||
color: white;
|
||||
}
|
||||
|
||||
a strong :global(mark) {
|
||||
color: var(--sk-text-1);
|
||||
background: var(--highlight-color);
|
||||
outline: 2px solid var(--highlight-color);
|
||||
/* border-top: 2px solid var(--highlight-color); */
|
||||
border-radius: 1px;
|
||||
}
|
||||
</style>
|
@ -1,28 +0,0 @@
|
||||
<script>
|
||||
import SearchResultList from './SearchResultList.svelte';
|
||||
|
||||
/** @type {import('./types').Tree[]} */
|
||||
export let results;
|
||||
|
||||
/** @type {string} */
|
||||
export let query;
|
||||
</script>
|
||||
|
||||
{#if results.length > 0}
|
||||
<SearchResultList {results} {query} on:select />
|
||||
{:else if query}
|
||||
<p class="info">No results</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.info {
|
||||
padding: 1rem;
|
||||
font-size: 1.2rem;
|
||||
font-weight: normal;
|
||||
text-transform: uppercase;
|
||||
background-color: var(--sk-back-2);
|
||||
border-radius: 0 0 var(--sk-border-radius) var(--sk-border-radius);
|
||||
pointer-events: all;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
@ -1,107 +0,0 @@
|
||||
import flexsearch from 'flexsearch';
|
||||
|
||||
// @ts-expect-error
|
||||
const Index = /** @type {import('flexsearch').Index} */ (flexsearch.Index ?? flexsearch);
|
||||
|
||||
export let inited = false;
|
||||
|
||||
/** @type {import('flexsearch').Index[]} */
|
||||
let indexes;
|
||||
|
||||
/** @type {Map<string, import('./types').Block>} */
|
||||
const map = new Map();
|
||||
|
||||
/** @type {Map<string, string>} */
|
||||
const hrefs = new Map();
|
||||
|
||||
/** @param {import('./types').Block[]} blocks */
|
||||
export function init(blocks) {
|
||||
if (inited) return;
|
||||
|
||||
// we have multiple indexes, so we can rank sections (migration guide comes last)
|
||||
const max_rank = Math.max(...blocks.map((block) => block.rank ?? 0));
|
||||
|
||||
indexes = Array.from({ length: max_rank + 1 }, () => new Index({ tokenize: 'forward' }));
|
||||
|
||||
for (const block of blocks) {
|
||||
const title = block.breadcrumbs.at(-1);
|
||||
map.set(block.href, block);
|
||||
// NOTE: we're not using a number as the ID here, but it is recommended:
|
||||
// https://github.com/nextapps-de/flexsearch#use-numeric-ids
|
||||
// If we were to switch to a number we would need a second map from ID to block
|
||||
// We need to keep the existing one to allow looking up recent searches by URL even if docs change
|
||||
// It's unclear how much browsers do string interning and how this might affect memory
|
||||
// We'd probably want to test both implementations across browsers if memory usage becomes an issue
|
||||
// TODO: fix the type by updating flexsearch after
|
||||
// https://github.com/nextapps-de/flexsearch/pull/364 is merged and released
|
||||
indexes[block.rank ?? 0].add(block.href, `${title} ${block.content}`);
|
||||
|
||||
hrefs.set(block.breadcrumbs.join('::'), block.href);
|
||||
}
|
||||
|
||||
inited = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} query
|
||||
* @returns {import('./types').Block[]}
|
||||
*/
|
||||
export function search(query) {
|
||||
const escaped = query.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
|
||||
const regex = new RegExp(`(^|\\b)${escaped}`, 'i');
|
||||
|
||||
const blocks = indexes
|
||||
.map((index) => index.search(query))
|
||||
.flat()
|
||||
.map(lookup)
|
||||
.map((block, rank) => ({ block, rank }))
|
||||
.sort((a, b) => {
|
||||
const a_title_matches = regex.test(a.block.breadcrumbs.at(-1));
|
||||
const b_title_matches = regex.test(b.block.breadcrumbs.at(-1));
|
||||
|
||||
// massage the order a bit, so that title matches
|
||||
// are given higher priority
|
||||
if (a_title_matches !== b_title_matches) {
|
||||
return a_title_matches ? -1 : 1;
|
||||
}
|
||||
|
||||
return a.block.breadcrumbs.length - b.block.breadcrumbs.length || a.rank - b.rank;
|
||||
})
|
||||
.map(({ block }) => block);
|
||||
|
||||
const results = tree([], blocks).children;
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/** @param {string} href */
|
||||
export function lookup(href) {
|
||||
return map.get(href);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} breadcrumbs
|
||||
* @param {import('./types').Block[]} blocks
|
||||
*/
|
||||
function tree(breadcrumbs, blocks) {
|
||||
const depth = breadcrumbs.length;
|
||||
|
||||
const node = blocks.find((block) => {
|
||||
if (block.breadcrumbs.length !== depth) return false;
|
||||
return breadcrumbs.every((part, i) => block.breadcrumbs[i] === part);
|
||||
});
|
||||
|
||||
const descendants = blocks.filter((block) => {
|
||||
if (block.breadcrumbs.length <= depth) return false;
|
||||
return breadcrumbs.every((part, i) => block.breadcrumbs[i] === part);
|
||||
});
|
||||
|
||||
const child_parts = Array.from(new Set(descendants.map((block) => block.breadcrumbs[depth])));
|
||||
|
||||
return {
|
||||
breadcrumbs,
|
||||
href: hrefs.get(breadcrumbs.join('::')),
|
||||
node,
|
||||
children: child_parts.map((part) => tree([...breadcrumbs, part], descendants)),
|
||||
};
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { persisted } from 'svelte-local-storage-store';
|
||||
|
||||
export const searching = writable(false);
|
||||
export const query = writable('');
|
||||
|
||||
/** @type {import('svelte/store').Writable<any[]>} */
|
||||
export const recent = persisted('recent_searches', []);
|
@ -1,13 +0,0 @@
|
||||
export interface Block {
|
||||
breadcrumbs: string[];
|
||||
href: string;
|
||||
content: string;
|
||||
rank: number;
|
||||
}
|
||||
|
||||
export interface Tree {
|
||||
breadcrumbs: string[];
|
||||
href: string;
|
||||
node: Block;
|
||||
children: Tree[];
|
||||
}
|
@ -1,26 +0,0 @@
|
||||
import { init, search, lookup } from '../search/search.js';
|
||||
|
||||
addEventListener('message', async (event) => {
|
||||
const { type, payload } = event.data;
|
||||
|
||||
if (type === 'init') {
|
||||
const res = await fetch(`${payload.origin}/content.json`);
|
||||
const { blocks } = await res.json();
|
||||
init(blocks);
|
||||
|
||||
postMessage({ type: 'ready' });
|
||||
}
|
||||
|
||||
if (type === 'query') {
|
||||
const query = payload;
|
||||
const results = search(query);
|
||||
|
||||
postMessage({ type: 'results', payload: { results, query } });
|
||||
}
|
||||
|
||||
if (type === 'recents') {
|
||||
const results = payload.map(lookup).filter(Boolean);
|
||||
|
||||
postMessage({ type: 'recents', payload: results });
|
||||
}
|
||||
});
|
Loading…
Reference in new issue