mirror of https://github.com/sveltejs/svelte
feat: search and backlink compatability (#8286)
* Copy the right files * Finish search * FIx accessibility issues * Add original site compatibility back in * Remove console.log * Reorganize imports * Minor refactor * Fix undefined heading issue * Replace state on redirect * Don't redirect to docs/introduction from navbar * Cleanup search * Cleanup some more(html entities) * Remove console log * Minor style tweaks * Put search in middlepull/8288/head
parent
1b12546afe
commit
df2bb23af4
@ -0,0 +1,68 @@
|
||||
/** @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);
|
||||
}
|
||||
};
|
||||
}
|
@ -0,0 +1,130 @@
|
||||
<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>
|
@ -0,0 +1,420 @@
|
||||
<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>
|
@ -0,0 +1,163 @@
|
||||
<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>
|
@ -0,0 +1,28 @@
|
||||
<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>
|
@ -0,0 +1,133 @@
|
||||
import { normalizeSlugify, removeMarkdown } from '$lib/server/docs';
|
||||
import { extract_frontmatter, transform } from '$lib/server/markdown';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import glob from 'tiny-glob/sync.js';
|
||||
|
||||
const base = '../../site/content/';
|
||||
|
||||
const categories = [
|
||||
{
|
||||
slug: 'docs',
|
||||
label: null,
|
||||
/** @param {string[]} parts */
|
||||
href: (parts) =>
|
||||
parts.length > 1 ? `/docs/${parts[0]}#${parts.slice(1).join('-')}` : `/docs/${parts[0]}`,
|
||||
},
|
||||
{
|
||||
slug: 'faq',
|
||||
label: 'FAQ',
|
||||
/** @param {string[]} parts */
|
||||
href: (parts) => `/faq#${parts.join('-')}`,
|
||||
},
|
||||
];
|
||||
|
||||
export function content() {
|
||||
/** @type {import('./types').Block[]} */
|
||||
const blocks = [];
|
||||
|
||||
for (const category of categories) {
|
||||
const breadcrumbs = category.label ? [category.label] : [];
|
||||
|
||||
for (const file of glob('**/*.md', { cwd: `${base}/${category.slug}` })) {
|
||||
const basename = path.basename(file);
|
||||
const match = /\d{2}-(.+)\.md/.exec(basename);
|
||||
if (!match) continue;
|
||||
|
||||
const slug = match[1];
|
||||
|
||||
const filepath = `${base}/${category.slug}/${file}`;
|
||||
// const markdown = replace_placeholders(fs.readFileSync(filepath, 'utf-8'));
|
||||
const markdown = fs.readFileSync(filepath, 'utf-8');
|
||||
|
||||
const { body, metadata } = extract_frontmatter(markdown);
|
||||
|
||||
const sections = body.trim().split(/^### /m);
|
||||
const intro = sections.shift().trim();
|
||||
const rank = +metadata.rank || undefined;
|
||||
|
||||
blocks.push({
|
||||
breadcrumbs: [...breadcrumbs, removeMarkdown(metadata.title ?? '')],
|
||||
href: category.href([slug]),
|
||||
content: plaintext(intro),
|
||||
rank,
|
||||
});
|
||||
|
||||
for (const section of sections) {
|
||||
const lines = section.split('\n');
|
||||
const h3 = lines.shift();
|
||||
const content = lines.join('\n');
|
||||
|
||||
const subsections = content.trim().split('### ');
|
||||
|
||||
const intro = subsections.shift().trim();
|
||||
|
||||
blocks.push({
|
||||
breadcrumbs: [...breadcrumbs, removeMarkdown(metadata.title), removeMarkdown(h3)],
|
||||
href: category.href([slug, normalizeSlugify(h3)]),
|
||||
content: plaintext(intro),
|
||||
rank,
|
||||
});
|
||||
|
||||
for (const subsection of subsections) {
|
||||
const lines = subsection.split('\n');
|
||||
const h4 = lines.shift();
|
||||
|
||||
blocks.push({
|
||||
breadcrumbs: [
|
||||
...breadcrumbs,
|
||||
removeMarkdown(metadata.title),
|
||||
removeMarkdown(h3),
|
||||
removeMarkdown(h4),
|
||||
],
|
||||
href: category.href([slug, normalizeSlugify(h3), normalizeSlugify(h4)]),
|
||||
content: plaintext(lines.join('\n').trim()),
|
||||
rank,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/** @param {string} markdown */
|
||||
function plaintext(markdown) {
|
||||
/** @param {unknown} text */
|
||||
const block = (text) => `${text}\n`;
|
||||
|
||||
/** @param {string} text */
|
||||
const inline = (text) => text;
|
||||
|
||||
return transform(markdown, {
|
||||
code: (source) => source.split('// ---cut---\n').pop(),
|
||||
blockquote: block,
|
||||
html: () => '\n',
|
||||
heading: (text) => `${text}\n`,
|
||||
hr: () => '',
|
||||
list: block,
|
||||
listitem: block,
|
||||
checkbox: block,
|
||||
paragraph: (text) => `${text}\n\n`,
|
||||
table: block,
|
||||
tablerow: block,
|
||||
tablecell: (text, opts) => {
|
||||
return text + ' ';
|
||||
},
|
||||
strong: inline,
|
||||
em: inline,
|
||||
codespan: inline,
|
||||
br: () => '',
|
||||
del: inline,
|
||||
link: (href, title, text) => text,
|
||||
image: (href, title, text) => text,
|
||||
text: inline,
|
||||
})
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&#(\d+);/g, (match, code) => {
|
||||
return String.fromCharCode(code);
|
||||
})
|
||||
.trim();
|
||||
}
|
@ -0,0 +1,107 @@
|
||||
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)),
|
||||
};
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
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', []);
|
@ -0,0 +1,13 @@
|
||||
export interface Block {
|
||||
breadcrumbs: string[];
|
||||
href: string;
|
||||
content: string;
|
||||
rank: number;
|
||||
}
|
||||
|
||||
export interface Tree {
|
||||
breadcrumbs: string[];
|
||||
href: string;
|
||||
node: Block;
|
||||
children: Tree[];
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
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 });
|
||||
}
|
||||
});
|
@ -0,0 +1,11 @@
|
||||
import { content } from '$lib/search/content';
|
||||
import { json } from '@sveltejs/kit';
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
/** @type {import('./$types').RequestHandler} */
|
||||
export function GET() {
|
||||
return json({
|
||||
blocks: content(),
|
||||
});
|
||||
}
|
@ -1,7 +1 @@
|
||||
import { base } from '$app/paths';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
/** @type {import('./$types').PageLoad} */
|
||||
export async function load() {
|
||||
throw redirect(307, `${base}/docs/introduction`);
|
||||
}
|
||||
export const prerender = true;
|
||||
|
@ -0,0 +1,25 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
/** @type {import('./$types').PageData} */
|
||||
export let data;
|
||||
|
||||
function getURlToRedirectTo() {
|
||||
const section = data.sections.find((val) =>
|
||||
$page.url.hash.replace('#', '').startsWith(val.path.split('/').at(-1))
|
||||
);
|
||||
|
||||
if (!section) return '/docs/introduction';
|
||||
|
||||
// Remove the section name from hash, then redirect to that
|
||||
const hash = $page.url.hash.replace(`#${section.path.split('/').at(-1)}-`, '');
|
||||
|
||||
return `${section.path}#${hash}`;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
goto(getURlToRedirectTo(), { replaceState: true });
|
||||
});
|
||||
</script>
|
@ -0,0 +1 @@
|
||||
export const prerender = false;
|
@ -0,0 +1,21 @@
|
||||
import { init, inited, search } from '$lib/search/search.js';
|
||||
|
||||
/** @type {import('./$types').PageServerLoad} */
|
||||
export async function load({ url, fetch }) {
|
||||
if (!inited) {
|
||||
const res = await fetch('/content.json');
|
||||
if (!res.ok) throw new Error("Couldn't fetch content");
|
||||
|
||||
const blocks = (await res.json()).blocks;
|
||||
init(blocks);
|
||||
}
|
||||
|
||||
const query = url.searchParams.get('q');
|
||||
|
||||
const results = query ? search(query) : [];
|
||||
|
||||
return {
|
||||
query,
|
||||
results
|
||||
};
|
||||
}
|
@ -0,0 +1,49 @@
|
||||
<script>
|
||||
import SearchResults from '$lib/search/SearchResults.svelte';
|
||||
|
||||
/** @type {import('./$types').PageData} */
|
||||
export let data;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Search • SvelteKit</title>
|
||||
</svelte:head>
|
||||
|
||||
<main>
|
||||
<h1>Search</h1>
|
||||
<form>
|
||||
<input name="q" value={data.query} placeholder="Search" spellcheck="false" />
|
||||
</form>
|
||||
|
||||
<SearchResults results={data.results} query={data.query} />
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: 8rem 1rem;
|
||||
}
|
||||
|
||||
form {
|
||||
--size: 4rem;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: var(--size);
|
||||
margin: 0 0 2em 0;
|
||||
}
|
||||
|
||||
input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
border: 1px solid var(--sk-back-5);
|
||||
border-radius: var(--sk-border-radius);
|
||||
padding-left: var(--size);
|
||||
border-radius: var(--size);
|
||||
background: no-repeat 1rem 50% / 2rem 2rem url(/icons/search.svg);
|
||||
color: var(--sk-text-1);
|
||||
}
|
||||
</style>
|
Before Width: | Height: | Size: 215 B After Width: | Height: | Size: 393 B |
After Width: | Height: | Size: 356 B |
Loading…
Reference in new issue