Add search to website (#8624)

This commit is contained in:
Ali Abdalla 2024-07-03 21:57:11 -07:00 committed by GitHub
parent 64ac05b111
commit ba59bb824f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 602 additions and 93 deletions

View File

@ -0,0 +1,5 @@
---
"website": patch
---
feat:Add search to website

View File

@ -210,6 +210,17 @@ def organize_docs(d):
pages = organize_pages()
# content_json = {}
# def generate_content_json(pages):
# for library in pages:
# for category in pages[library]:
# for page in category["pages"]:
# page_path = os.path.join(TEMPLATES_DIR, page["path"] + ".svx")
# with open(page_path) as f:
# content = f.read()
# content_json["content"] = content
organized["gradio"]["events_matrix"] = component_events
organized["gradio"]["events"] = events

View File

@ -18,7 +18,8 @@
"@tailwindcss/typography": "^0.5.4",
"@types/prismjs": "^1.26.0",
"prismjs": "1.29.0",
"tailwindcss": "^3.1.6"
"tailwindcss": "^3.1.6",
"flexsearch": "^0.7.43"
},
"type": "module",
"dependencies": {

View File

@ -196,10 +196,7 @@ code.language-bash {
}
[type="search"]::-webkit-search-cancel-button {
@apply appearance-none h-5 w-5;
-webkit-appearance: none;
background-image: url("/src/lib/assets/img/esc.svg");
background-size: 20px 20px;
display: none;
}
.view-code {

View File

@ -6,40 +6,9 @@
export let current_nav_link = "";
let show_nav = false;
let searchTerm = "";
let searchBar: HTMLInputElement;
const search = () => {
let links = document.querySelectorAll(
".navigation a"
) as NodeListOf<HTMLAnchorElement>;
links.forEach((link) => {
let linkText = link.innerText.toLowerCase();
if (linkText.includes(searchTerm.toLowerCase())) {
link.style.display = "block";
} else {
link.style.display = "none";
}
});
};
function onKeyDown(e: KeyboardEvent) {
if (e.key.toLowerCase() === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
searchBar.focus();
}
if (e.key == "Escape") {
searchTerm = "";
searchBar.blur();
search();
}
}
import DropDown from "$lib/components/VersionDropdown.svelte";
</script>
<svelte:window on:keydown={onKeyDown} />
<section
class="top-0 left-0 fixed flex items-center p-4 rounded-br-lg backdrop-blur-lg z-50 bg-gray-200/50 lg:hidden"
id="menu-bar"
@ -88,16 +57,6 @@
<div
class="w-full sticky top-0 bg-gradient-to-r from-white to-gray-50 z-10 hidden lg:block my-4 ml-4"
>
<input
bind:value={searchTerm}
on:input={search}
bind:this={searchBar}
id="search"
type="search"
class="w-4/5 rounded-md border-gray-200 focus:placeholder-transparent focus:shadow-none focus:border-orange-500 focus:ring-0"
placeholder="Search ⌘-k / ctrl-k"
autocomplete="off"
/>
<DropDown></DropDown>
</div>

View File

@ -1,7 +1,8 @@
<script lang="ts">
import { store } from "../../routes/+layout.svelte";
import { gradio_logo, github_black } from "../assets";
import { gradio_logo } from "../assets";
import Search from "./search";
let click_nav = false;
let show_help_menu = false;
@ -103,16 +104,17 @@
class="thin-link inline-block px-4 py-2 hover:bg-gray-100"
href="/brand">Brand</a
>
<a
class="thin-link inline-block px-4 py-2 hover:bg-gray-100"
href="https://github.com/gradio-app/gradio"
>
Github
</a>
</div>
{/if}
</div>
<a
class="thin-link flex items-center gap-3"
href="https://github.com/gradio-app/gradio"
>
<img src={github_black} class="w-6" alt="Github logo" />
</a>
<Search />
</nav>
</div>
</div>

View File

@ -9,40 +9,10 @@
let docs_type = "js";
let show_nav = false;
let searchTerm = "";
let searchBar: HTMLInputElement;
const search = () => {
let links = document.querySelectorAll(
".navigation a"
) as NodeListOf<HTMLAnchorElement>;
links.forEach((link) => {
let linkText = link.innerText.toLowerCase();
if (linkText.includes(searchTerm.toLowerCase())) {
link.style.display = "block";
} else {
link.style.display = "none";
}
});
};
function onKeyDown(e: KeyboardEvent) {
if (e.key.toLowerCase() === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
searchBar.focus();
}
if (e.key == "Escape") {
searchTerm = "";
searchBar.blur();
search();
}
}
import DropDown from "$lib/components/VersionDropdown.svelte";
</script>
<svelte:window on:keydown={onKeyDown} />
<section
class="top-0 fixed -ml-4 flex items-center p-4 rounded-br-lg backdrop-blur-lg z-50 bg-gray-200/50 lg:hidden"
id="menu-bar"
@ -91,16 +61,6 @@
<div
class="w-full sticky top-0 bg-gradient-to-r from-white to-gray-50 z-10 hidden lg:block my-4 ml-4"
>
<input
bind:value={searchTerm}
on:input={search}
bind:this={searchBar}
id="search"
type="search"
class="w-4/5 rounded-md border-gray-200 focus:placeholder-transparent focus:shadow-none focus:border-orange-500 focus:ring-0"
placeholder="Search ⌘-k / ctrl-k"
autocomplete="off"
/>
{#if version_dropdown}
<DropDown docs_type="js"></DropDown>
{/if}

View File

@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<title>Search</title>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>

After

Width:  |  Height:  |  Size: 285 B

View File

@ -0,0 +1 @@
export { default } from "./search.svelte";

View File

@ -0,0 +1,17 @@
import { create_pages_index, search_pages_index } from "./search";
addEventListener("message", async (e) => {
const { type, payload } = e.data;
if (type === "load") {
const posts = await fetch("/search-api").then((res) => res.json());
create_pages_index(posts);
postMessage({ type: "ready" });
}
if (type === "search") {
const search_term = payload.search_term;
const results = search_pages_index(search_term);
postMessage({ type: "results", payload: { results, search_term } });
}
});

View File

@ -0,0 +1,344 @@
<script lang="ts">
import Search_Worker from "./search-worker?worker";
import SearchIcon from "./SearchIcon.svelte";
import { onNavigate } from "$app/navigation";
import type { Result } from "./search";
import { browser } from "$app/environment";
let search: "idle" | "load" | "ready" = "idle";
let search_term = "";
let results: Result[] = [];
let search_worker: Worker;
function initialize() {
open = true;
if (search === "ready") return;
search = "load";
search_worker = new Search_Worker();
search_worker.addEventListener("message", (e) => {
const { type, payload } = e.data;
type === "ready" && (search = "ready");
type === "results" && (results = payload.results);
});
search_worker.postMessage({ type: "load" });
}
let open: boolean = false;
onNavigate(() => {
open = false;
});
$: if (search === "ready") {
search_worker.postMessage({ type: "search", payload: { search_term } });
}
$: if (search_term && !open) {
search_term = "";
}
let content_elem: HTMLElement;
let search_button_elem: HTMLElement;
function focus_input(el: HTMLInputElement) {
el.focus();
}
function get_os() {
// @ts-ignore - userAgentData is not yet in the TS types as it is currently experimental
return navigator.userAgentData.platform ?? navigator.userAgent;
}
let meta_key = "⌘";
$: if (browser && navigator) {
let os = get_os();
meta_key = os.includes("Mac") || os.includes("mac") ? "⌘" : "CTRL+";
}
function handle_key_down(e: KeyboardEvent): void {
if (e.ctrlKey || e.metaKey) {
if (e.key === "k" || e.key === "K") {
e.preventDefault();
initialize();
}
}
if (e.key === "Escape") {
open = false;
}
if ((e.key === "ArrowUp" || e.key === "ArrowDown") && open) {
e.preventDefault();
const current = document.activeElement;
const items = [...document.getElementsByClassName("res-block")];
const current_index = current ? items.indexOf(current) : -1;
let new_index;
if (current_index === -1) {
new_index = 1;
} else {
if (e.key === "ArrowUp") {
new_index = (current_index + items.length - 1) % items.length;
} else {
new_index = (current_index + 1) % items.length;
}
}
(current as HTMLElement).blur();
const newElement = items[new_index] as HTMLElement;
if (newElement) {
newElement.focus();
document
.querySelectorAll(".res-block")
.forEach((el) => el.classList.remove("first-res"));
}
}
const search_input = document.getElementById(
"search-input"
) as HTMLInputElement;
const first_result = document.querySelector(".res-block") as HTMLElement;
if (e.key === "Enter" && document.activeElement === search_input) {
first_result.click();
}
}
function on_click(e: MouseEvent) {
if (content_elem) {
if (!content_elem.contains(e.target as Node) && open) {
open = false;
}
} else {
if (search_button_elem.contains(e.target as Node)) {
initialize();
}
}
}
$: if (browser && document.querySelector(".res-block")) {
document.querySelector(".res-block")?.classList.add("first-res");
}
</script>
<svelte:window on:keydown={handle_key_down} on:click={on_click} />
<button class="search-button" bind:this={search_button_elem}>
<SearchIcon />
<span class="pl-1 pr-5">Search</span>
<div class="shortcut">
<div class="text-sm">
{meta_key}K
</div>
</div>
</button>
{#if open}
<div class="overlay" />
<div class="content" bind:this={content_elem}>
<div class="search-bar">
{#if search === "load"}
<div class="loader"></div>
{:else}
<SearchIcon />
{/if}
<input
bind:value={search_term}
placeholder="What are you searching for?"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
enterkeyhint="go"
maxlength="64"
spellcheck="false"
type="search"
use:focus_input
id="search-input"
/>
<button
on:click={() => {
open = false;
}}
class="text-xs font-semibold rounded-md p-1 border-gray-300 border"
>
ESC
</button>
</div>
<div class="results">
{#if results.length}
<ul>
{#each results as result, i}
{#if result.content.length > 0}
<li>
<a
class="res-block"
class:first-res={i === 0}
href={result.slug}
>
<p
class:text-green-700={result.type == "DOCS"}
class:bg-green-100={result.type == "DOCS"}
class:text-orange-700={result.type == "GUIDE"}
class:bg-orange-100={result.type == "GUIDE"}
class="float-left text-xs font-semibold rounded-md p-1 px-2 mx-1 mt-[3px]"
>
{result.type}
</p>
<div class="float-right">
<div class="enter"></div>
</div>
<p>{@html result.title}</p>
<ol>
{#each result.content as content}
<li class="res-content">{@html content}</li>
{/each}
</ol>
</a>
</li>
{/if}
{/each}
</ul>
{:else}
{#if search_term}
{#if search === "load"}
<p class="mx-auto w-fit text-gray-500">Searching for results...</p>
{:else}
<p class="mx-auto w-fit text-gray-500">
No results found. Try using a different term.
</p>
{/if}
{/if}
<ul>
<p class="">Suggestions</p>
<li>
<a class="res-block first-res" href="/quickstart">
<p
class="float-left text-xs mx-1 font-semibold text-orange-700 bg-orange-100 rounded-md p-1 px-2 mt-[3px]"
>
GUIDE
</p>
<div class="float-right">
<div class="enter"></div>
</div>
<p>Quickstart</p>
</a>
</li>
<li>
<a class="res-block" href="/docs/gradio/interface">
<p
class="float-left text-xs font-semibold text-green-700 bg-green-100 rounded-md p-1 px-2 mx-1 mt-[3px]"
>
DOCS
</p>
<div class="float-right">
<div class="enter"></div>
</div>
<p>Interface</p>
</a>
</li>
<li>
<a class="res-block" href="/docs/gradio/blocks">
<p
class="float-left text-xs font-semibold text-green-700 bg-green-100 rounded-md p-1 px-2 mx-1 mt-[3px]"
>
DOCS
</p>
<div class="float-right">
<div class="enter"></div>
</div>
<p>Blocks</p>
</a>
</li>
</ul>
{/if}
</div>
</div>
{/if}
<style>
.overlay {
@apply fixed inset-0 z-30 backdrop-blur-sm bg-black/20;
}
.search-bar {
@apply font-sans text-lg z-10 px-4 relative flex flex-none items-center border-b border-gray-100 text-gray-500;
}
.search-bar input {
@apply text-lg appearance-none h-14 text-black mx-1 flex-auto min-w-0 border-none cursor-text;
outline: none;
box-shadow: none;
}
.content {
@apply fixed left-1/2 top-1/4 -translate-x-1/2 mx-auto w-screen max-w-3xl flex flex-col min-h-0 rounded-lg shadow-2xl bg-white z-40;
}
.results {
@apply p-5 overflow-y-auto;
max-height: 60vh;
scrollbar-width: thin;
& ol {
margin-block-start: 2px;
}
& li:not(:last-child) {
margin-block-end: 4px;
padding-block-end: 4px;
}
& a {
display: block;
}
}
.search-button {
@apply flex flex-row rounded-full items-center cursor-pointer px-2 text-gray-400 border-gray-300 border text-lg outline-none font-sans;
}
:global(.res-content .mark) {
color: #ff7c00;
text-decoration: underline;
}
:global(.res-content) {
@apply text-gray-500;
}
:global(.res-block) {
@apply m-2 p-2 border border-gray-100 rounded-md bg-gray-50 hover:bg-gray-100 hover:scale-[1.01] focus:bg-gray-100 focus:scale-[1.01] focus:outline-none;
}
:global(.first-res) {
@apply bg-gray-100 scale-[1.01];
}
:global(.res-block:focus .enter) {
display: block !important;
}
:global(.first-res .enter) {
display: block !important;
}
.enter {
display: none;
}
.enter {
@apply text-xs font-semibold rounded-md p-1 border-gray-300 border text-gray-500 font-sans bg-white;
}
.loader {
border: 1px solid #d0cfcf;
border-top: 2px solid #475469;
border-radius: 50%;
width: 15px;
height: 15px;
animation: spin 1.2s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@ -0,0 +1,71 @@
import FlexSearch from "flexsearch";
export type Page = {
content: string;
slug: string;
title: string;
type: string;
};
export type Result = {
content: string[];
slug: string;
title: string;
type?: string;
};
let pages_index: FlexSearch.Index;
let pages: Page[];
export function create_pages_index(data: Page[]) {
pages_index = new FlexSearch.Index({ tokenize: "forward" });
data.forEach((page, i) => {
const item = `${page.title} ${page.content}`;
pages_index.add(i, item);
});
pages = data;
}
export function search_pages_index(search_term: string) {
const match = search_term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const results = pages_index.search(match);
return results
.map((index) => pages[index as number])
.map(({ slug, title, content, type }) => {
return {
slug,
title: replace_text_with_marker(title, match),
content: get_matches(content, match),
type
};
});
}
function replace_text_with_marker(text: string, match: string) {
const regex = new RegExp(match, "gi");
return text.replaceAll(
regex,
(match) => `<span class='mark'>${match}</span>`
);
}
function get_matches(text: string, search_term: string, limit = 1) {
const regex = new RegExp(search_term, "gi");
const indexes = [];
let matches = 0;
let match;
while ((match = regex.exec(text)) !== null && matches < limit) {
indexes.push(match.index);
matches++;
}
return indexes.map((index) => {
const start = index - 20;
const end = index + 80;
const excerpt = text.substring(start, end).trim();
return `...${replace_text_with_marker(excerpt, search_term)}...`;
});
}

View File

@ -0,0 +1,118 @@
import { json } from "@sveltejs/kit";
export const prerender = true;
function removeMarkdown(markdown) {
return markdown
.replace(/^#{1,6}\s+/gm, "")
.replace(/(\*\*|__)(.*?)\1/g, "$2")
.replace(/(\*|_)(.*?)\1/g, "$2")
.replace(/~~(.*?)~~/g, "$1")
.replace(/`([^`]+)`/g, "$1")
.replace(/```[\s\S]*?```/g, "")
.replace(/!\[.*?\]\(.*?\)/g, "")
.replace(/\[(.*?)\]\(.*?\)/g, "$1")
.replace(/^>\s+/gm, "")
.replace(/^---$/gm, "")
.replace(/^\s*[-+*]\s+/gm, "")
.replace(/^\s*\d+\.\s+/gm, "")
.replace(/\n{2,}/g, "\n")
.trim();
}
export async function GET() {
const gradio_doc_paths = import.meta.glob(
"/src/lib/templates/gradio/**/*.svx"
);
const gradio_doc_pages = await Promise.all(
Object.entries(gradio_doc_paths).map(async ([path, content]) => {
content = await content();
content = content.default.render().html;
let match = content.match(/<h1[^>]*>(.*?)<\/h1>/i);
let title = "";
if (match && match[1]) {
title = match[1];
}
path = path.split("/").slice(-1)[0];
path = path.match(/(?:\d{2}_)?(.+)/i)[1];
path = "/main/docs/gradio/" + path.split(".svx")[0];
return {
title: title,
slug: path,
content: content.replaceAll(/<[^>]*>?/gm, ""),
type: "DOCS"
};
})
);
const client_doc_paths = import.meta.glob(
"/src/lib/templates/python-client/**/*.svx"
);
const client_doc_pages = await Promise.all(
Object.entries(client_doc_paths).map(async ([path, content]) => {
content = await content();
content = content.default.render().html;
let match = content.match(/<h1[^>]*>(.*?)<\/h1>/i);
let title = "";
if (match && match[1]) {
title = match[1];
}
path = path.split("/").slice(-1)[0];
path = path.match(/(?:\d{2}_)?(.+)/i)[1];
path = "/main/docs/python-client/" + path.split(".svx")[0];
return {
title: title,
slug: path,
content: content.replaceAll(/<[^>]*>?/gm, ""),
type: "DOCS"
};
})
);
const guide_paths = import.meta.glob("/src/lib/json/guides/*.json");
delete guide_paths["/src/lib/json/guides/guides_by_category.json"];
delete guide_paths["/src/lib/json/guides/guide_names.json"];
const guide_pages = await Promise.all(
Object.entries(guide_paths).map(async ([path, content]) => {
content = await content();
content = content.default.guide;
return {
title: content.pretty_name,
slug: content.url,
content: removeMarkdown(content.content.replaceAll(/<[^>]*>?/gm, "")),
type: "GUIDE"
};
})
);
const jsons_path = import.meta.glob("/src/lib/json/docs.json");
const jsons_content = await jsons_path["/src/lib/json/docs.json"]();
const js_client_page = {
title: "JavaScript Client Library",
slug: "/docs/js-client",
content: removeMarkdown(jsons_content.default.js_client),
type: "DOCS"
};
const js_components = jsons_content.default.js;
const js_pages = await Promise.all(
Object.entries(js_components).map(async ([name, content]) => {
return {
title: name,
slug: "/docs/js/" + name,
content: removeMarkdown(content.replaceAll(/<[^>]*>?/gm, "")),
type: "DOCS"
};
})
);
let all_pages = gradio_doc_pages
.concat(client_doc_pages)
.concat(guide_pages)
.concat([js_client_page])
.concat(js_pages);
return json(all_pages);
}

8
pnpm-lock.yaml generated
View File

@ -458,6 +458,9 @@ importers:
'@types/prismjs':
specifier: ^1.26.0
version: 1.26.1
flexsearch:
specifier: ^0.7.43
version: 0.7.43
prismjs:
specifier: 1.29.0
version: 1.29.0
@ -6261,6 +6264,9 @@ packages:
flatted@3.3.1:
resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==}
flexsearch@0.7.43:
resolution: {integrity: sha512-c5o/+Um8aqCSOXGcZoqZOm+NqtVwNsvVpWv6lfmSclU954O3wvQKxxK8zj74fPaSJbXpSLTs4PRhh+wnoCXnKg==}
flow-parser@0.235.1:
resolution: {integrity: sha512-s04193L4JE+ntEcQXbD6jxRRlyj9QXcgEl2W6xSjH4l9x4b0eHoCHfbYHjqf9LdZFUiM5LhgpiqsvLj/AyOyYQ==}
engines: {node: '>=0.4.0'}
@ -14816,6 +14822,8 @@ snapshots:
flatted@3.3.1: {}
flexsearch@0.7.43: {}
flow-parser@0.235.1: {}
for-each@0.3.3: