Skip to content
Merged
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.

4 changes: 2 additions & 2 deletions packages/stacks-docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"check": "npm run build -w @stackoverflow/stacks-svelte && npm run build:package -w @stackoverflow/stacks-email && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "npm run check && eslint .",
"test": "vitest run src/lib",
"format": "prettier --write .",
"init-private-content": "if [ -d src/docs/private/.git ]; then git -C src/docs/private pull --ff-only; elif [ -s ~/.ssh/id_rsa ]; then GIT_SSH_COMMAND=\"ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no\" git clone --depth 1 git@github.com:StackEng/StacksDocsPrivate.git src/docs/private; else echo 'Skipping private docs; no SSH key found'; fi"
},
Expand All @@ -24,8 +25,6 @@
"vite": "^7.3.3"
},
"dependencies": {
"@docsearch/css": "^4.3.2",
"@docsearch/js": "^4.3.2",
"@stackoverflow/stacks-email": "*",
"@hbsnow/rehype-sectionize": "^1.0.7",
"@stackoverflow/stacks": "*",
Expand All @@ -36,6 +35,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 @@ -620,12 +620,3 @@ h1 {
.hmn6 .s-editor-resizable {
min-height: var(--su640) !important;
}

/* Agolia Docsearch */

.DocSearch-Container {
z-index: 9999 !important;
}
.DocSearch-Footer {
margin: 0;
}
8 changes: 8 additions & 0 deletions packages/stacks-docs/src/app.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,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 Down
271 changes: 221 additions & 50 deletions packages/stacks-docs/src/components/Search.svelte
Original file line number Diff line number Diff line change
@@ -1,52 +1,223 @@
<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 { limitSearchResults } from "$lib/searchResults";

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

let searchInput = $state<HTMLInputElement>();
let isOpen = $state(false);
let query = $state("");
let searchDocuments = $state<DocsSearchDocument[]>([]);
let isLoading = $state(false);
let hasLoaded = $state(false);
let loadError = $state(false);
let searchDocumentsRequest: Promise<void> | undefined;

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 resultSet = $derived.by(() => {
const trimmedQuery = query.trim();
if (!trimmedQuery) return limitSearchResults([]);

const matches = miniSearch.search(trimmedQuery) as Array<
SearchResult &
Pick<DocsSearchDocument, "title" | "description" | "path">
>;

return limitSearchResults(matches);
});
const results = $derived(resultSet.results);

const hasQuery = $derived(query.trim().length > 0);
const resultStatus = $derived.by(() => {
if (isLoading) return "Loading search.";
if (loadError) return "Search is currently unavailable.";
if (!hasQuery) return "";

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

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

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

function toggleSearch() {
isOpen = !isOpen;
if (isOpen) void loadSearchDocuments();
}

function openSearch() {
isOpen = true;
void loadSearchDocuments();
}

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

function loadSearchDocuments() {
if (hasLoaded) return Promise.resolve();
if (searchDocumentsRequest) return searchDocumentsRequest;

isLoading = true;
loadError = false;
searchDocumentsRequest = fetch(resolve("/search.json"))
.then((response) => {
if (!response.ok) throw new Error("Failed to load search data");
return response.json() as Promise<DocsSearchDocument[]>;
})
.then((documents) => {
searchDocuments = documents;
hasLoaded = true;
})
.catch(() => {
loadError = true;
searchDocumentsRequest = undefined;
})
.finally(() => {
isLoading = false;
});

return searchDocumentsRequest;
}

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 isLoading}
<EmptyState title="Loading search" class="p24">
{#snippet description()}
Preparing the documentation index.
{/snippet}
</EmptyState>
{:else if loadError}
<EmptyState title="Search unavailable" class="p24">
{#snippet description()}
Close and reopen search to try again.
{/snippet}
</EmptyState>
{:else 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