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 middle
pull/8288/head
Puru Vijay 2 years ago committed by GitHub
parent 1b12546afe
commit df2bb23af4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -13,8 +13,10 @@
"cookie": "^0.5.0", "cookie": "^0.5.0",
"devalue": "^4.3.0", "devalue": "^4.3.0",
"do-not-zip": "^1.0.0", "do-not-zip": "^1.0.0",
"flexsearch": "^0.7.31",
"flru": "^1.0.2", "flru": "^1.0.2",
"sourcemap-codec": "^1.4.8" "sourcemap-codec": "^1.4.8",
"svelte-local-storage-store": "^0.4.0"
}, },
"devDependencies": { "devDependencies": {
"@resvg/resvg-js": "^2.4.0", "@resvg/resvg-js": "^2.4.0",
@ -2069,6 +2071,11 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/flexsearch": {
"version": "0.7.31",
"resolved": "https://registry.npmjs.org/flexsearch/-/flexsearch-0.7.31.tgz",
"integrity": "sha512-XGozTsMPYkm+6b5QL3Z9wQcJjNYxp0CYn3U1gO7dwD6PAqU1SVWZxI9CCg3z+ml3YfqdPnrBehaBrnH2AGKbNA=="
},
"node_modules/flru": { "node_modules/flru": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/flru/-/flru-1.0.2.tgz", "resolved": "https://registry.npmjs.org/flru/-/flru-1.0.2.tgz",
@ -3554,7 +3561,6 @@
"version": "3.55.1", "version": "3.55.1",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-3.55.1.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-3.55.1.tgz",
"integrity": "sha512-S+87/P0Ve67HxKkEV23iCdAh/SX1xiSfjF1HOglno/YTbSTW7RniICMCofWGdJJbdjw3S+0PfFb1JtGfTXE0oQ==", "integrity": "sha512-S+87/P0Ve67HxKkEV23iCdAh/SX1xiSfjF1HOglno/YTbSTW7RniICMCofWGdJJbdjw3S+0PfFb1JtGfTXE0oQ==",
"dev": true,
"engines": { "engines": {
"node": ">= 8" "node": ">= 8"
} }
@ -3598,6 +3604,17 @@
"resolved": "https://registry.npmjs.org/svelte-json-tree/-/svelte-json-tree-1.0.0.tgz", "resolved": "https://registry.npmjs.org/svelte-json-tree/-/svelte-json-tree-1.0.0.tgz",
"integrity": "sha512-scs1OdkC8uFpTN4MX0yKkOzZ1/EG3eP1ARC+xcFthXp2IfcwBaXgab0FqA4Am0vQwffNNB+1Gd1LFkJBlynWTA==" "integrity": "sha512-scs1OdkC8uFpTN4MX0yKkOzZ1/EG3eP1ARC+xcFthXp2IfcwBaXgab0FqA4Am0vQwffNNB+1Gd1LFkJBlynWTA=="
}, },
"node_modules/svelte-local-storage-store": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/svelte-local-storage-store/-/svelte-local-storage-store-0.4.0.tgz",
"integrity": "sha512-ctPykTt4S3BE5bF0mfV0jKiUR1qlmqLvnAkQvYHLeb9wRyO1MdIFDVI23X+TZEFleATHkTaOpYZswIvf3b2tWA==",
"engines": {
"node": ">=0.14"
},
"peerDependencies": {
"svelte": "^3.48.0"
}
},
"node_modules/svelte-preprocess": { "node_modules/svelte-preprocess": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.0.1.tgz", "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.0.1.tgz",
@ -5496,6 +5513,11 @@
"to-regex-range": "^5.0.1" "to-regex-range": "^5.0.1"
} }
}, },
"flexsearch": {
"version": "0.7.31",
"resolved": "https://registry.npmjs.org/flexsearch/-/flexsearch-0.7.31.tgz",
"integrity": "sha512-XGozTsMPYkm+6b5QL3Z9wQcJjNYxp0CYn3U1gO7dwD6PAqU1SVWZxI9CCg3z+ml3YfqdPnrBehaBrnH2AGKbNA=="
},
"flru": { "flru": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/flru/-/flru-1.0.2.tgz", "resolved": "https://registry.npmjs.org/flru/-/flru-1.0.2.tgz",
@ -6576,8 +6598,7 @@
"svelte": { "svelte": {
"version": "3.55.1", "version": "3.55.1",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-3.55.1.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-3.55.1.tgz",
"integrity": "sha512-S+87/P0Ve67HxKkEV23iCdAh/SX1xiSfjF1HOglno/YTbSTW7RniICMCofWGdJJbdjw3S+0PfFb1JtGfTXE0oQ==", "integrity": "sha512-S+87/P0Ve67HxKkEV23iCdAh/SX1xiSfjF1HOglno/YTbSTW7RniICMCofWGdJJbdjw3S+0PfFb1JtGfTXE0oQ=="
"dev": true
}, },
"svelte-check": { "svelte-check": {
"version": "3.0.3", "version": "3.0.3",
@ -6607,6 +6628,12 @@
"resolved": "https://registry.npmjs.org/svelte-json-tree/-/svelte-json-tree-1.0.0.tgz", "resolved": "https://registry.npmjs.org/svelte-json-tree/-/svelte-json-tree-1.0.0.tgz",
"integrity": "sha512-scs1OdkC8uFpTN4MX0yKkOzZ1/EG3eP1ARC+xcFthXp2IfcwBaXgab0FqA4Am0vQwffNNB+1Gd1LFkJBlynWTA==" "integrity": "sha512-scs1OdkC8uFpTN4MX0yKkOzZ1/EG3eP1ARC+xcFthXp2IfcwBaXgab0FqA4Am0vQwffNNB+1Gd1LFkJBlynWTA=="
}, },
"svelte-local-storage-store": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/svelte-local-storage-store/-/svelte-local-storage-store-0.4.0.tgz",
"integrity": "sha512-ctPykTt4S3BE5bF0mfV0jKiUR1qlmqLvnAkQvYHLeb9wRyO1MdIFDVI23X+TZEFleATHkTaOpYZswIvf3b2tWA==",
"requires": {}
},
"svelte-preprocess": { "svelte-preprocess": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.0.1.tgz", "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.0.1.tgz",

@ -21,8 +21,10 @@
"cookie": "^0.5.0", "cookie": "^0.5.0",
"devalue": "^4.3.0", "devalue": "^4.3.0",
"do-not-zip": "^1.0.0", "do-not-zip": "^1.0.0",
"flexsearch": "^0.7.31",
"flru": "^1.0.2", "flru": "^1.0.2",
"sourcemap-codec": "^1.4.8" "sourcemap-codec": "^1.4.8",
"svelte-local-storage-store": "^0.4.0"
}, },
"devDependencies": { "devDependencies": {
"@resvg/resvg-js": "^2.4.0", "@resvg/resvg-js": "^2.4.0",

@ -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, '&lt;').replace(/>/g, '&gt;').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(/&lt;/g, '<')
.replace(/&gt;/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[];
}

@ -14,6 +14,42 @@ import MagicString from 'magic-string';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
/** @param {string} title */
export function slugify(title) {
return title
.toLowerCase()
.replace(/&#39;/g, '')
.replace(/&lt;/g, '')
.replace(/&gt;/g, '')
.replace(/[^a-z0-9-$]/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^-/, '')
.replace(/-$/, '');
}
/** @param {string} markdown */
export function removeMarkdown(markdown) {
return markdown
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/\*(.+?)\*/g, '$1')
.replace(/`(.+?)`/g, '$1')
.replace(/~~(.+?)~~/g, '$1')
.replace(/\[(.+?)\]\(.+?\)/g, '$1')
.replace(/\n/g, ' ')
.replace(/ {2,}/g, ' ')
.trim();
}
/** @param {string} html */
export function removeHTMLEntities(html) {
return html.replace(/&.+?;/g, '');
}
/** @param {string} str */
export const normalizeSlugify = (str) => {
return slugify(removeHTMLEntities(removeMarkdown(str))).replace(/(<([^>]+)>)/gi, '');
};
const base = '../../site/content/docs/'; const base = '../../site/content/docs/';
const languages = { const languages = {
@ -286,18 +322,6 @@ export async function read_file(file) {
}; };
} }
/** @param {string} title */
export function slugify(title) {
return title
.toLowerCase()
.replace(/&lt;/g, '')
.replace(/&gt;/g, '')
.replace(/[^a-z0-9-$]/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^-/, '')
.replace(/-$/, '');
}
/** /**
* @param {{ * @param {{
* file: string; * file: string;
@ -325,7 +349,7 @@ function parse({ file, body, code, codespan }) {
* @param {string} html * @param {string} html
* @param {number} level * @param {number} level
*/ */
heading(html, level) { heading(html, level, raw) {
const title = html const title = html
.replace(/<\/?code>/g, '') .replace(/<\/?code>/g, '')
.replace(/&quot;/g, '"') .replace(/&quot;/g, '"')
@ -334,7 +358,7 @@ function parse({ file, body, code, codespan }) {
current = title; current = title;
const normalized = slugify(title); const normalized = normalizeSlugify(raw);
headings[level - 1] = normalized; headings[level - 1] = normalized;
headings.length = level; headings.length = level;

@ -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 });
}
});

@ -1,6 +1,9 @@
<script> <script>
import { browser } from '$app/environment';
import { navigating, page } from '$app/stores'; import { navigating, page } from '$app/stores';
import PreloadingIndicator from '$lib/components/PreloadingIndicator.svelte'; import PreloadingIndicator from '$lib/components/PreloadingIndicator.svelte';
import Search from '$lib/search/Search.svelte';
import SearchBox from '$lib/search/SearchBox.svelte';
import { Icon, Icons, Nav, NavItem, SkipLink } from '@sveltejs/site-kit'; import { Icon, Icons, Nav, NavItem, SkipLink } from '@sveltejs/site-kit';
import '@sveltejs/site-kit/styles/index.css'; import '@sveltejs/site-kit/styles/index.css';
</script> </script>
@ -15,15 +18,24 @@
<SkipLink href="#main" /> <SkipLink href="#main" />
<Nav {page} logo="/svelte-logo.svg"> <Nav {page} logo="/svelte-logo.svg">
<svelte:fragment slot="nav-center"> <svelte:fragment slot="nav-center">
{#if $page.url.pathname !== '/search'}
<!-- the <Nav> component renders this content inside a <ul>, so
we need to wrap it in an <li>. TODO if we adopt this design
on other sites, change <Nav> so we don't need to do this -->
<li><Search /></li>
{/if}
</svelte:fragment>
<svelte:fragment slot="nav-right">
<NavItem href="/tutorial">Tutorial</NavItem> <NavItem href="/tutorial">Tutorial</NavItem>
<NavItem href="/docs">Docs</NavItem> <NavItem href="/docs/introduction">Docs</NavItem>
<NavItem href="/examples">Examples</NavItem> <NavItem href="/examples">Examples</NavItem>
<NavItem href="/repl">REPL</NavItem> <NavItem href="/repl">REPL</NavItem>
<NavItem href="/blog">Blog</NavItem> <NavItem href="/blog">Blog</NavItem>
<NavItem href="/faq">FAQ</NavItem> <NavItem href="/faq">FAQ</NavItem>
</svelte:fragment>
<svelte:fragment slot="nav-right"> <li aria-hidden="true"><span class="separator" /></li>
<NavItem external="https://kit.svelte.dev">SvelteKit</NavItem> <NavItem external="https://kit.svelte.dev">SvelteKit</NavItem>
<NavItem external="/chat" title="Discord Chat"> <NavItem external="/chat" title="Discord Chat">
@ -51,6 +63,10 @@
<slot /> <slot />
</main> </main>
{#if browser}
<SearchBox />
{/if}
<style> <style>
:global(:root) { :global(:root) {
color-scheme: light dark; color-scheme: light dark;
@ -78,6 +94,14 @@
display: none; display: none;
} }
.separator {
display: block;
position: relative;
height: 1px;
margin: 0.5rem 0;
background: radial-gradient(circle at center, rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.05));
}
@media (min-width: 800px) { @media (min-width: 800px) {
.small { .small {
display: none; display: none;
@ -86,6 +110,23 @@
.large { .large {
display: inline; display: inline;
} }
.separator {
display: flex;
align-items: center;
justify-content: center;
background: none;
height: 100%;
margin: 0;
border: none;
text-align: center;
}
.separator::before {
content: '•';
margin: 0 0.3rem;
color: #ccc;
}
} }
:global(html, body) { :global(html, body) {

@ -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,15 +1,19 @@
<script> <script>
import { page } from '$app/stores'; import { page } from '$app/stores';
import Contents from './Contents.svelte'; import Contents from './Contents.svelte';
import '@sveltejs/site-kit/styles/code.css';
/** @type {import('./$types').LayoutServerData}*/ /** @type {import('./$types').LayoutServerData}*/
export let data; export let data;
$: title = data.sections.find((val) => val.path === $page.url.pathname)?.title;
</script> </script>
<div class="container"> <div class="container">
<div class="page content "> <div class="page content ">
<h1>{data.sections.find((val) => val.path === $page.url.pathname)?.title}</h1> {#if title}
<h1>{title}</h1>
{/if}
<slot /> <slot />
</div> </div>
@ -80,10 +84,10 @@
height: 0; height: 0;
} }
.content :global(.anchor) { .content :global(a.permalink) {
position: absolute; position: absolute;
display: block; display: block;
background: url(../icons/link.svg) 0 50% no-repeat; background: url(/icons/link.svg) 0 50% no-repeat;
background-size: 1em 1em; background-size: 1em 1em;
width: 1.4em; width: 1.4em;
height: 1em; height: 1em;
@ -94,11 +98,11 @@
user-select: none; user-select: none;
} }
.content :global(h2) :global(.anchor) { .content :global(h2) :global(.permalink) {
bottom: 4rem; bottom: 2rem;
} }
.content :global(h3) :global(.anchor) { .content :global(h3) :global(.permalink) {
bottom: 1rem; bottom: 1rem;
} }
@ -114,11 +118,7 @@
} }
.content :global(.anchor:focus), .content :global(.anchor:focus),
.content :global(h2):hover :global(.anchor), .content :global(:where(h2, h3, h4, h5, h6):hover .permalink) {
.content :global(h3):hover :global(.anchor),
.content :global(h4):hover :global(.anchor),
.content :global(h5):hover :global(.anchor),
.content :global(h6):hover :global(.anchor) {
opacity: 1; opacity: 1;
} }
} }

@ -1,7 +1 @@
import { base } from '$app/paths'; export const prerender = true;
import { redirect } from '@sveltejs/kit';
/** @type {import('./$types').PageLoad} */
export async function load() {
throw redirect(307, `${base}/docs/introduction`);
}

@ -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>

@ -33,8 +33,9 @@
.faqs { .faqs {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
grid-gap: 1em; grid-gap: 1em;
min-height: calc(100vh - var(--nav-h)); min-height: calc(100vh - var(--sk-nav-height));
padding: var(--top-offset) var(--side-nav) 6rem var(--side-nav); padding: var(--sk-page-padding-top) var(--sk-page-padding-side) 6rem var(--sk-page-padding-side);
/* TODO: REMOVE */
max-width: var(--main-width); max-width: var(--main-width);
margin: 0 auto; margin: 0 auto;
tab-size: 2; tab-size: 2;
@ -50,7 +51,7 @@
.faqs :global(pre) { .faqs :global(pre) {
margin: 0 0 2rem 0; margin: 0 0 2rem 0;
width: 100%; width: 100%;
max-width: var(--linemax); max-width: var(--sk-line-max-width);
padding: 1.5rem 2.5rem; padding: 1.5rem 2.5rem;
background: #333; background: #333;
border-radius: 0.5rem; border-radius: 0.5rem;
@ -60,7 +61,7 @@
.faqs :global(.offset-anchor) { .faqs :global(.offset-anchor) {
position: relative; position: relative;
display: block; display: block;
top: calc(-1 * var(--top-offset)); top: calc(-1 * var(--sk-page-padding-top));
width: 0; width: 0;
height: 0; height: 0;
} }
@ -101,9 +102,9 @@
h2 { h2 {
margin: 3.5rem 0 1rem 0; margin: 3.5rem 0 1rem 0;
padding: 0 0 0.2em 0; padding: 0 0 0.2em 0;
color: var(--text); color: var(--sk-text-2);
/* max-width: 24em; */ /* max-width: 24em; */
font-size: var(--h3); font-size: var(--sk-text-m);
font-weight: 400; font-weight: 400;
border-bottom: 1px solid #ddd; border-bottom: 1px solid #ddd;
} }
@ -112,7 +113,7 @@
font-family: inherit; font-family: inherit;
font-weight: 600; font-weight: 600;
font-size: 2rem; font-size: 2rem;
color: var(--second); color: var(--sk-theme-2);
margin: 2rem 0 1.6rem 0; margin: 2rem 0 1.6rem 0;
padding-left: 0; padding-left: 0;
background: transparent; background: transparent;
@ -124,12 +125,12 @@
.faq:first-child { .faq:first-child {
margin: 0 0 2rem 0; margin: 0 0 2rem 0;
padding: 0 0 4rem 0; padding: 0 0 4rem 0;
border-bottom: var(--border-w) solid #6767785b; /* based on --second */ border-bottom: var(--sk-thick-border-width) solid #6767785b; /* based on --second */
} }
.faq:first-child h2 { .faq:first-child h2 {
font-size: 4rem; font-size: 4rem;
font-weight: 400; font-weight: 400;
color: var(--second); color: var(--sk-theme-2);
} }
:global(.faqs .faq ul) { :global(.faqs .faq ul) {
@ -137,7 +138,7 @@
} }
.faqs :global(.anchor) { .faqs :global(.anchor) {
top: calc((var(--h3) - 24px) / 2); top: calc((var(--sk-text-m) - 24px) / 2);
} }
@media (max-width: 768px) { @media (max-width: 768px) {

@ -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>

@ -186,6 +186,7 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
display: grid; display: grid;
/* TODO */
grid-template-columns: minmax(33.333%, var(--sidebar-large-w)) auto; grid-template-columns: minmax(33.333%, var(--sidebar-large-w)) auto;
grid-auto-rows: 100%; grid-auto-rows: 100%;
transition: none; transition: none;

@ -1 +1,19 @@
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="#333"><path d="m9 7h-3a2 2 0 0 0 0 10h3"/><path d="m15 7h3a2 2 0 0 1 0 10h-3"/><path d="m7 12h10"/></g></svg> <?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" viewBox="0 0 24 24">
<style>
path {
stroke: #333;
fill: none;
}
@media (prefers-color-scheme: dark) {
path {
stroke: #ccc;
}
}
</style>
<path d="M9,7L6,7A2 2 0 0 0 6,17L9,17"/>
<path d="M15,7L18,7A2 2 0 0 1 18,17L15,17"/>
<path d="M7,12L17,12"/>
</svg>

Before

Width:  |  Height:  |  Size: 215 B

After

Width:  |  Height:  |  Size: 393 B

@ -0,0 +1,6 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
fill="#ccc"
d="M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 356 B

Loading…
Cancel
Save