Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions packages/stacks-docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@
"vite": "^7.3.3"
},
"dependencies": {
"@docsearch/css": "^4.3.2",
"@docsearch/js": "^4.3.2",
"@hbsnow/rehype-sectionize": "^1.0.7",
"@stackoverflow/stacks": "*",
"@stackoverflow/stacks-editor": "*",
Expand All @@ -34,6 +32,7 @@
"@stefanprobst/rehype-extract-toc": "^3.0.0",
"better-auth": "^1.6.23",
"highlight.js": "^11.11.1",
"minisearch": "^7.2.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-slug": "^6.0.0",
"turndown": "^7.2.2",
Expand Down
9 changes: 0 additions & 9 deletions packages/stacks-docs/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -608,12 +608,3 @@ h1 {
.hmn6 .s-editor-resizable {
min-height: var(--su640) !important;
}

/* Agolia Docsearch */

.DocSearch-Container {
z-index: 9999 !important;
}
.DocSearch-Footer {
margin: 0;
}
9 changes: 9 additions & 0 deletions packages/stacks-docs/src/app.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ declare global {
[key: string]: unknown;
};

type DocsSearchDocument = {
id: string;
title: string;
description: string;
path: string;
text: string;
};

namespace App {
interface Locals {
session?: unknown;
Expand All @@ -47,6 +55,7 @@ declare global {
markdown?: string | null;
metadata?: DocsMetadata | null;
needsAuth?: boolean;
searchDocuments?: DocsSearchDocument[];
source?: string | null;
structure?: { navigation?: DocsNavItem[] };
[key: string]: unknown;
Expand Down
222 changes: 172 additions & 50 deletions packages/stacks-docs/src/components/Search.svelte
Original file line number Diff line number Diff line change
@@ -1,52 +1,174 @@
<script>
import { untrack } from 'svelte';
import docsearch from '@docsearch/js';
import '@docsearch/css';

import { env } from '$env/dynamic/public';

import { Button, Icon } from '@stackoverflow/stacks-svelte';
import { IconSearch } from '@stackoverflow/stacks-icons';

let docSearchButton = $state();

// Check if all required env vars are available
const searchEnabled = env.PUBLIC_ALGOLIA_APP_ID && env.PUBLIC_ALGOLIA_API_KEY && env.PUBLIC_ALGOLIA_INDEX_NAME;

$effect(() => {
if (!searchEnabled) return;

const container = document.createElement('div');
container.style.display = 'none';
document.body.appendChild(container);

try {
docsearch({
container,
appId: env.PUBLIC_ALGOLIA_APP_ID,
apiKey: env.PUBLIC_ALGOLIA_API_KEY,
indexName: env.PUBLIC_ALGOLIA_INDEX_NAME,
});

// Store reference to the DocSearch button
docSearchButton = untrack(() => container.querySelector('.DocSearch-Button'));
} catch (error) {
console.warn('Search initialization failed:', error);
}

return () => {
container.remove();
};
});

function openSearch() {
docSearchButton?.click();
}
<script lang="ts">
import { resolve } from "$app/paths";
import { tick } from "svelte";
import MiniSearch, { type SearchResult } from "minisearch";

import {
Button,
EmptyState,
Icon,
Modal,
} from "@stackoverflow/stacks-svelte";
import { IconSearch } from "@stackoverflow/stacks-icons";

let { searchDocuments = [] }: { searchDocuments?: DocsSearchDocument[] } =
$props();

let searchInput = $state<HTMLInputElement>();
let isOpen = $state(false);
let query = $state("");

const miniSearch = $derived.by(() => {
const search = new MiniSearch<DocsSearchDocument>({
fields: ["title", "description", "text"],
storeFields: ["title", "description", "path"],
searchOptions: {
boost: { title: 3, description: 2 },
fuzzy: 0.2,
prefix: true,
},
});

search.addAll(searchDocuments);
return search;
});

const results = $derived.by(() => {
const trimmedQuery = query.trim();
if (!trimmedQuery) return [];

return miniSearch.search(trimmedQuery).slice(0, 8) as Array<
SearchResult &
Pick<DocsSearchDocument, "title" | "description" | "path">
>;
});

const hasQuery = $derived(query.trim().length > 0);
const resultStatus = $derived.by(() => {
if (!hasQuery) return "";

return `${results.length} search ${results.length === 1 ? "result" : "results"} found.`;
});

$effect(() => {
if (!isOpen) return;

tick().then(() => searchInput?.focus());
});

function toggleSearch() {
isOpen = !isOpen;
}

function openSearch() {
isOpen = true;
}

function closeSearch() {
isOpen = false;
query = "";
}

function handleWindowKeydown(event: KeyboardEvent) {
if (
(event.metaKey || event.ctrlKey) &&
event.key.toLowerCase() === "k"
) {
event.preventDefault();
openSearch();
}
}
</script>

{#if searchEnabled}
<Button icon weight="clear" class="h:fc-blue-400 px0" onclick={openSearch}>
<Icon src={IconSearch} />
<span class="v-visible-sr">Search</span>
</Button>
{/if}
<svelte:window onkeydown={handleWindowKeydown} />

<Button
icon
weight="clear"
class="s-btn__xs mrn6 px6 h:fc-blue-400"
onclick={toggleSearch}
>
<Icon src={IconSearch} />
<span class="v-visible-sr">Search</span>
</Button>

<Modal
id="docs-search"
visible={isOpen}
class="search-modal d-flex fd-column w100 wmx5 hs5 hmx-screen overflow-hidden px0 pb0"
i18nCloseButtonLabel="Close search"
onclose={closeSearch}
>
{#snippet header()}
Search documentation
{/snippet}

{#snippet body()}
<div class="d-flex ai-center bb bc-black-200 px24 pb12">
<input
bind:this={searchInput}
bind:value={query}
class="s-input"
placeholder="Search documentation..."
aria-label="Search documentation"
/>
</div>

<p class="v-visible-sr" aria-live="polite">{resultStatus}</p>

<div class="search-results fl-shrink1 overflow-auto h5 hmx100 hmn0 px24 py8">
{#if results.length}
<ul class="list-reset m0" aria-label="Search results">
{#each results as result (result.id)}
<li>
<a
class="d-block p12 bar-sm fc-black-600 h:bg-black-100 h:fc-black-600 td-none"
href={resolve(result.path as `/${string}`)}
onclick={closeSearch}
>
<span class="d-block fw-bold mb2"
>{result.title}</span
>
{#if result.description}
<span
class="d-block fs-caption truncate fc-black-500 lh-md"
>{result.description}</span
>
{/if}
<span class="d-block fs-fine fc-black-400 mt4"
>{result.path}</span
>
</a>
</li>
{/each}
</ul>
{:else if hasQuery}
<EmptyState title="No results found" class="p24">
{#snippet description()}
Try a different search term.
{/snippet}
</EmptyState>
{:else}
<p class="m0 p12 fs-body1 fc-black-500">
Search components, guidelines, and foundations.
</p>
{/if}
</div>
{/snippet}
</Modal>

<style>
:global(#docs-search-title) {
padding-left: var(--su24);
padding-right: var(--su24);
}

:global(#docs-search-description) {
display: flex;
flex-direction: column;
margin-bottom: 0;
min-height: 0;
overflow: visible;
}

</style>
Loading
Loading