From 0950f7f49ee4247963b56a4de627686ef7bbd6cf Mon Sep 17 00:00:00 2001 From: saberzero1 Date: Sat, 7 Feb 2026 07:37:21 +0100 Subject: [PATCH] feat: port Search component from Quartz --- README.md | 284 ++++++------- package-lock.json | 4 +- package.json | 17 +- src/components/ExampleComponent.tsx | 59 --- src/components/Search.tsx | 638 ++++++++++++++++++++++++++++ src/components/index.ts | 2 +- src/emitter.ts | 88 ---- src/filter.ts | 55 --- src/index.ts | 12 +- src/transformer.ts | 104 ----- src/types.ts | 54 --- tsup.config.ts | 1 - 12 files changed, 783 insertions(+), 535 deletions(-) delete mode 100644 src/components/ExampleComponent.tsx create mode 100644 src/components/Search.tsx delete mode 100644 src/emitter.ts delete mode 100644 src/filter.ts delete mode 100644 src/transformer.ts delete mode 100644 src/types.ts diff --git a/README.md b/README.md index 34d004a..e279af2 100644 --- a/README.md +++ b/README.md @@ -1,165 +1,145 @@ -# Quartz Community Plugin Template +# @quartz-community/search -Production-ready template for building, testing, and publishing Quartz community plugins. It mirrors -Quartz's native plugin patterns and uses a factory-function API similar to Astro integrations: -plugins are created by functions that return objects with `name` and lifecycle hooks. +The Search component for Quartz - full-text search with FlexSearch integration. -## Highlights +This is a first-party community plugin for Quartz, demonstrating the new plugin system that allows components to be distributed as npm packages. It provides instant full-text search across your content using FlexSearch for fast indexing and retrieval. -- ✅ Quartz-compatible transformer/filter/emitter examples -- ✅ TypeScript-first with exported types for consumers -- ✅ `tsup` bundling + declaration output -- ✅ Vitest testing setup with example tests -- ✅ Linting/formatting with ESLint + Prettier -- ✅ CI workflow for checks and npm publishing -- ✅ Demonstrates CSS/JS resource injection and remark/rehype usage +## Features -## Getting started +- 🔍 **Full-Text Search** - Search across all your content instantly +- ⚡ **Fast Indexing** - Uses FlexSearch for high-performance search +- 📱 **Mobile Responsive** - Works great on all devices +- 🎯 **Search Preview** - Optional content preview panel +- 🌐 **Multi-Language** - Supports 30+ locales +- ⌨️ **Keyboard Shortcuts** - `Ctrl/Cmd + K` to open, `Escape` to close +- 🏷️ **Tag Search** - Search by tags with special syntax + +## Installation + +### From GitHub (Recommended for now) + +```bash +npm install github:quartz-community/search --legacy-peer-deps +``` + +### From NPM (when published) + +```bash +npm install @quartz-community/search +``` + +## Usage + +### 1. Configure in quartz.config.ts + +Add the plugin to your externalPlugins array: + +```typescript +// quartz.config.ts +import { QuartzConfig } from "./quartz/cfg"; + +const config: QuartzConfig = { + configuration: { + // ... your configuration + }, + plugins: { + // ... your existing plugins + }, + externalPlugins: ["@quartz-community/search"], +}; + +export default config; +``` + +### 2. Import in your layout + +```typescript +// quartz.layout.ts +import { Search } from "@quartz-community/search"; + +// Create the Search component once +const searchComponent = Search({ + enablePreview: true, + placeholder: "Search for something", + title: "Search", +}); + +export const defaultContentPageLayout: PageLayout = { + // ... other layout config + left: [ + searchComponent, + // ... other components + ], +}; + +export const defaultListPageLayout: PageLayout = { + // ... other layout config + left: [ + searchComponent, // Reuse the same component instance + // ... other components + ], +}; +``` + +## Configuration Options + +```typescript +interface SearchOptions { + /** Enable content preview panel */ + enablePreview?: boolean; + /** Custom placeholder text */ + placeholder?: string; + /** Custom title for the search button */ + title?: string; +} +``` + +## Default Behavior + +By default, the search component: + +- Displays as a button with a search icon +- Opens a fullscreen search modal when clicked +- Shows up to 8 search results +- Enables content preview on desktop (can be disabled) +- Supports keyboard navigation (arrow keys, Enter, Escape) +- Uses FlexSearch from CDN for indexing + +## How It Works + +The Search component: + +1. Loads FlexSearch library from CDN when initialized +2. Fetches content data from `/static/contentIndex.json` +3. Builds a search index from your content +4. Performs real-time search as you type +5. Shows results with optional content preview + +> [!info] +> Search requires the `ContentIndex` emitter plugin to be present in your Quartz configuration. + +## Keyboard Shortcuts + +- `Ctrl/Cmd + K` - Open/Close search +- `Escape` - Close search +- `Arrow Up/Down` - Navigate results +- `Enter` - Open selected result + +## Development + +This is a first-party Quartz community plugin. It serves as both: + +1. A production-ready Search component +2. A reference implementation for building Quartz community plugins + +To build locally: ```bash npm install npm run build ``` -## Usage in Quartz - -Install your plugin into a Quartz site and register it in `quartz.config.ts`: - -```ts -import { - ExampleTransformer, - ExampleFilter, - ExampleEmitter, -} from "@quartz-community/plugin-template"; - -export default { - configuration: { - pageTitle: "My Garden", - }, - plugins: { - transformers: [ExampleTransformer({ highlightToken: "==" })], - filters: [ExampleFilter({ allowDrafts: false })], - emitters: [ExampleEmitter({ manifestSlug: "plugin-manifest" })], - }, -}; -``` - -## Plugin factory pattern (Astro-style) - -Quartz plugins are factory functions that return an object with a `name` and hook implementations. -This mirrors Astro's integration pattern (a function returning an object of hooks), which makes -composition and configuration explicit and predictable. - -```ts -import type { QuartzTransformerPlugin } from "@jackyzha0/quartz/plugins/types"; - -export const MyTransformer: QuartzTransformerPlugin<{ enabled: boolean }> = (opts) => { - return { - name: "MyTransformer", - markdownPlugins() { - return []; - }, - }; -}; -``` - -## Examples included - -### Transformer - -`ExampleTransformer` shows how to: - -- apply a custom remark plugin -- run a rehype plugin -- inject CSS/JS resources -- perform a text transform hook - -```ts -import { ExampleTransformer } from "@quartz-community/plugin-template"; - -ExampleTransformer({ - highlightToken: "==", - headingClass: "example-plugin-heading", - enableGfm: true, - addHeadingSlugs: true, -}); -``` - -The transformer uses a custom remark plugin to convert `==highlight==` into bold text and a rehype -plugin to attach a class to all headings. It also injects a small inline CSS/JS snippet. - -### Filter - -`ExampleFilter` demonstrates frontmatter-driven filtering: - -```ts -ExampleFilter({ - allowDrafts: false, - excludeTags: ["private", "wip"], - excludePathPrefixes: ["_drafts/", "_private/"], -}); -``` - -### Emitter - -`ExampleEmitter` emits a JSON manifest of all pages: - -```ts -ExampleEmitter({ - manifestSlug: "plugin-manifest", - includeFrontmatter: true, - metadata: { project: "My Garden" }, - transformManifest: (json) => json.replace("My Garden", "Quartz"), -}); -``` - -## API reference - -### `ExampleTransformer(options)` - -| Option | Type | Default | Description | -| ----------------- | --------- | -------------------------- | ----------------------------- | -| `highlightToken` | `string` | `"=="` | Token used to highlight text. | -| `headingClass` | `string` | `"example-plugin-heading"` | Class added to headings. | -| `enableGfm` | `boolean` | `true` | Enables `remark-gfm`. | -| `addHeadingSlugs` | `boolean` | `true` | Enables `rehype-slug`. | - -### `ExampleFilter(options)` - -| Option | Type | Default | Description | -| --------------------- | ---------- | --------------------------- | ------------------------- | -| `allowDrafts` | `boolean` | `false` | Publish draft pages. | -| `excludeTags` | `string[]` | `["private"]` | Tags to exclude. | -| `excludePathPrefixes` | `string[]` | `["_drafts/", "_private/"]` | Path prefixes to exclude. | - -### `ExampleEmitter(options)` - -| Option | Type | Default | Description | -| --------------------- | -------------------------- | ----------------------------------------- | ----------------------------------------- | -| `manifestSlug` | `string` | `"plugin-manifest"` | Output filename (without extension). | -| `includeFrontmatter` | `boolean` | `true` | Include frontmatter in output. | -| `metadata` | `Record` | `{ generator: "Quartz Plugin Template" }` | Extra metadata in manifest. | -| `transformManifest` | `(json: string) => string` | `undefined` | Custom transformer for emitted JSON. | -| `manifestScriptClass` | `string` | `undefined` | Optional CSS class if rendered into HTML. | - -## Testing - -```bash -npm test -``` - -## Build and lint - -```bash -npm run build -npm run lint -npm run format -``` - -## Publishing - -Tags matching `v*` trigger the GitHub Actions publish workflow. Ensure `NPM_TOKEN` is set in the -repository secrets. +The `prepare` script automatically builds during installation. ## License diff --git a/package-lock.json b/package-lock.json index f3763e2..dc1eddc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@quartz-community/plugin-template", + "name": "@quartz-community/search", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@quartz-community/plugin-template", + "name": "@quartz-community/search", "version": "0.1.0", "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index d493b5f..f5b3a98 100644 --- a/package.json +++ b/package.json @@ -1,23 +1,22 @@ { - "name": "@quartz-community/plugin-template", + "name": "@quartz-community/search", "version": "0.1.0", - "description": "Template repository for Quartz community plugins.", + "description": "Search component for Quartz - full-text search with FlexSearch.", "type": "module", "license": "MIT", "author": "Quartz Community", - "homepage": "https://quartz.jzhao.xyz", + "homepage": "https://github.com/quartz-community/search", "repository": { "type": "git", - "url": "https://github.com/quartz-community/plugin-template" + "url": "https://github.com/quartz-community/search" }, "keywords": [ "quartz", "quartz-plugin", - "plugin-template", - "remark", - "rehype", - "mdast", - "hast" + "search", + "flexsearch", + "full-text-search", + "component" ], "files": [ "dist", diff --git a/src/components/ExampleComponent.tsx b/src/components/ExampleComponent.tsx deleted file mode 100644 index 9f1155c..0000000 --- a/src/components/ExampleComponent.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import type { VNode, JSX } from "preact"; - -export interface ExampleComponentOptions { - prefix?: string; - suffix?: string; - className?: string; -} - -export type QuartzComponentProps = { - ctx: any; // eslint-disable-line @typescript-eslint/no-explicit-any - externalResources: any; // eslint-disable-line @typescript-eslint/no-explicit-any - fileData: any; // eslint-disable-line @typescript-eslint/no-explicit-any - cfg: any; // eslint-disable-line @typescript-eslint/no-explicit-any - children: any; // eslint-disable-line @typescript-eslint/no-explicit-any - tree: any; // eslint-disable-line @typescript-eslint/no-explicit-any - allFiles: any[]; // eslint-disable-line @typescript-eslint/no-explicit-any - displayClass?: "mobile-only" | "desktop-only"; -} & JSX.IntrinsicAttributes & { - [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any - }; - -export interface QuartzComponent { - (props: QuartzComponentProps): VNode; - css?: string; - beforeDOMLoaded?: string; - afterDOMLoaded?: string; -} - -export const ExampleComponent = (opts?: ExampleComponentOptions) => { - const { prefix = "", suffix = "", className = "example-component" } = opts ?? {}; - - const Component: QuartzComponent = (props: QuartzComponentProps) => { - const title = props.fileData?.frontmatter?.title ?? "Untitled"; - const fullText = `${prefix}${title}${suffix}`; - - return
{fullText}
; - }; - - Component.css = ` - .example-component { - padding: 8px 16px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - border-radius: 4px; - font-weight: 600; - display: inline-block; - } - `; - - Component.afterDOMLoaded = ` - document.addEventListener('nav', () => { - console.log('ExampleComponent: page navigation occurred'); - }); - `; - - return Component; -}; - -export default ExampleComponent; diff --git a/src/components/Search.tsx b/src/components/Search.tsx new file mode 100644 index 0000000..bf5cbe5 --- /dev/null +++ b/src/components/Search.tsx @@ -0,0 +1,638 @@ +import type { VNode, JSX } from "preact"; + +export interface SearchOptions { + enablePreview?: boolean; + placeholder?: string; + title?: string; +} + +export type QuartzComponentProps = { + ctx: any; // eslint-disable-line @typescript-eslint/no-explicit-any + externalResources: any; // eslint-disable-line @typescript-eslint/no-explicit-any + fileData: any; // eslint-disable-line @typescript-eslint/no-explicit-any + cfg: any; // eslint-disable-line @typescript-eslint/no-explicit-any + children: any; // eslint-disable-line @typescript-eslint/no-explicit-any + tree: any; // eslint-disable-line @typescript-eslint/no-explicit-any + allFiles: any[]; // eslint-disable-line @typescript-eslint/no-explicit-any + displayClass?: "mobile-only" | "desktop-only"; +} & JSX.IntrinsicAttributes & { + [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any + }; + +export interface QuartzComponent { + (props: QuartzComponentProps): VNode; + css?: string; + beforeDOMLoaded?: string; + afterDOMLoaded?: string; +} + +const defaultOptions: SearchOptions = { + enablePreview: true, + placeholder: "Search for something", + title: "Search", +}; + +function classNames(...classes: (string | undefined | null | false)[]): string { + return classes.filter(Boolean).join(" "); +} + +const searchCSS = ` +.search { + min-width: fit-content; + max-width: 14rem; +} +@media all and (max-width: 800px) { + .search { + flex-grow: 0.3; + } +} + +.search > .search-button { + background-color: transparent; + border: 1px var(--lightgray) solid; + border-radius: 4px; + font-family: inherit; + font-size: inherit; + height: 2rem; + padding: 0 1rem 0 0; + display: flex; + align-items: center; + text-align: inherit; + cursor: pointer; + white-space: nowrap; + width: 100%; +} + +.search > .search-button > p { + display: inline; + color: var(--gray); + text-wrap: unset; +} + +.search > .search-button svg { + cursor: pointer; + width: 18px; + min-width: 18px; + margin: 0 0.5rem; +} + +.search > .search-button svg .search-path { + stroke: var(--darkgray); + stroke-width: 1.5px; + transition: stroke 0.5s ease; +} + +.search > .search-container { + position: fixed; + contain: layout; + z-index: 999; + left: 0; + top: 0; + width: 100vw; + height: 100vh; + overflow-y: auto; + display: none; + backdrop-filter: blur(4px); +} + +.search > .search-container.active { + display: inline-block; +} + +.search > .search-container > .search-space { + width: 65%; + margin-top: 12vh; + margin-left: auto; + margin-right: auto; +} + +@media all and not (min-width: 1200px) { + .search > .search-container > .search-space { + width: 90%; + } +} + +.search > .search-container > .search-space > * { + width: 100%; + border-radius: 7px; + background: var(--light); + box-shadow: 0 14px 50px rgba(27, 33, 48, 0.12), 0 10px 30px rgba(27, 33, 48, 0.16); + margin-bottom: 2em; +} + +.search > .search-container > .search-space > input { + box-sizing: border-box; + padding: 0.5em 1em; + font-family: var(--bodyFont); + color: var(--dark); + font-size: 1.1em; + border: 1px solid var(--lightgray); +} + +.search > .search-container > .search-space > input:focus { + outline: none; +} + +.search > .search-container > .search-space > .search-layout { + display: none; + flex-direction: row; + border: 1px solid var(--lightgray); + flex: 0 0 100%; + box-sizing: border-box; +} + +.search > .search-container > .search-space > .search-layout.display-results { + display: flex; +} + +.search > .search-container > .search-space > .search-layout[data-preview] > .results-container { + flex: 0 0 min(30%, 450px); +} + +@media all and not (max-width: 800px) { + .search > .search-container > .search-space > .search-layout[data-preview] .result-card > p.preview { + display: none; + } + + .search > .search-container > .search-space > .search-layout[data-preview] > div:first-child { + border-right: 1px solid var(--lightgray); + border-top-right-radius: unset; + border-bottom-right-radius: unset; + } + + .search > .search-container > .search-space > .search-layout[data-preview] > div:last-child { + border-top-left-radius: unset; + border-bottom-left-radius: unset; + } +} + +.search > .search-container > .search-space > .search-layout > div { + height: calc(75vh - 12vh); + border-radius: 5px; +} + +@media all and (max-width: 800px) { + .search > .search-container > .search-space > .search-layout { + flex-direction: column; + } + + .search > .search-container > .search-space > .search-layout > .preview-container { + display: none !important; + } + + .search > .search-container > .search-space > .search-layout[data-preview] > .results-container { + width: 100%; + height: auto; + flex: 0 0 100%; + } +} + +.search > .search-container > .search-space > .search-layout .highlight { + background: color-mix(in srgb, var(--tertiary) 60%, rgba(255, 255, 255, 0)); + border-radius: 5px; + scroll-margin-top: 2rem; +} + +.search > .search-container > .search-space > .search-layout > .preview-container { + flex-grow: 1; + display: block; + overflow: hidden; + font-family: inherit; + color: var(--dark); + line-height: 1.5em; + font-weight: 400; + overflow-y: auto; + padding: 0 2rem; +} + +.search > .search-container > .search-space > .search-layout > .preview-container .preview-inner { + margin: 0 auto; + width: min(100%, 100%); +} + +.search > .search-container > .search-space > .search-layout > .preview-container a[role="anchor"] { + background-color: transparent; +} + +.search > .search-container > .search-space > .search-layout > .results-container { + overflow-y: auto; +} + +.search > .search-container > .search-space > .search-layout > .results-container .result-card { + overflow: hidden; + padding: 1em; + cursor: pointer; + transition: background 0.2s ease; + border-bottom: 1px solid var(--lightgray); + width: 100%; + display: block; + box-sizing: border-box; + font-family: inherit; + font-size: 100%; + line-height: 1.15; + margin: 0; + text-transform: none; + text-align: left; + outline: none; + font-weight: inherit; +} + +.search > .search-container > .search-space > .search-layout > .results-container .result-card:hover, +.search > .search-container > .search-space > .search-layout > .results-container .result-card:focus, +.search > .search-container > .search-space > .search-layout > .results-container .result-card.focus { + background: var(--lightgray); +} + +.search > .search-container > .search-space > .search-layout > .results-container .result-card > h3 { + margin: 0; +} + +@media all and not (max-width: 800px) { + .search > .search-container > .search-space > .search-layout > .results-container .result-card > p.card-description { + display: none; + } +} + +.search > .search-container > .search-space > .search-layout > .results-container .result-card > ul.tags { + margin-top: 0.45rem; + margin-bottom: 0; +} + +.search > .search-container > .search-space > .search-layout > .results-container .result-card > ul > li > p { + border-radius: 8px; + background-color: var(--highlight); + padding: 0.2rem 0.4rem; + margin: 0 0.1rem; + line-height: 1.4rem; + font-weight: 700; + color: var(--secondary); +} + +.search > .search-container > .search-space > .search-layout > .results-container .result-card > ul > li > p.match-tag { + color: var(--tertiary); +} + +.search > .search-container > .search-space > .search-layout > .results-container .result-card > p { + margin-bottom: 0; +} +`; + +const searchScript = ` +(function() { + function loadScript(src) { + return new Promise(function(resolve, reject) { + var script = document.createElement("script"); + script.src = src; + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); + } + + loadScript("https://cdn.jsdelivr.net/npm/flexsearch@0.7.31/dist/flexsearch.bundle.min.js").then(function() { + initSearch(); + }).catch(function(err) { + console.error("[Search] Failed to load FlexSearch:", err); + }); + + function initSearch() { + var FlexSearch = window.FlexSearch; + if (!FlexSearch) { + console.error("[Search] FlexSearch not loaded"); + return; + } + + var index = new FlexSearch.Document({ + document: { + id: "id", + index: ["title", "content"] + } + }); + + var numSearchResults = 8; + var searchContainer = null; + var searchBar = null; + var searchLayout = null; + var resultsContainer = null; + var previewContainer = null; + var currentResults = []; + var currentIndex = -1; + var contentData = null; + + function removeAllChildren(el) { + while (el.firstChild) { + el.removeChild(el.firstChild); + } + } + + function highlightMatch(text, term) { + if (!term || !text) return text; + var specialChars = "[.*+?^$" + "{}()|" + "[\\]\\\\]"; + var escaped = term; + for (var i = 0; i < specialChars.length; i++) { + var char = specialChars[i]; + escaped = escaped.split(char).join("\\" + char); + } + var regex = new RegExp("(" + escaped + ")", "gi"); + return text.replace(regex, '$1'); + } + + function fetchContent(slug) { + if (!contentData) return null; + return contentData[slug] || null; + } + + async function initIndex() { + try { + var response = await fetch("/static/contentIndex.json"); + var data = await response.json(); + contentData = data.content || data; + + var id = 0; + for (var slug in contentData) { + var item = contentData[slug]; + index.add({ + id: id++, + slug: slug, + title: item.title || slug, + content: item.content || "" + }); + } + } catch (err) { + console.error("[Search] Error loading index:", err); + } + } + + function displayResults(results, term) { + removeAllChildren(resultsContainer); + currentResults = []; + currentIndex = -1; + + if (!results || results.length === 0) { + var noResults = document.createElement("div"); + noResults.className = "result-card"; + noResults.textContent = "No results found"; + resultsContainer.appendChild(noResults); + return; + } + + for (var i = 0; i < Math.min(results.length, numSearchResults); i++) { + var result = results[i]; + var item = fetchContent(result.slug); + if (!item) continue; + + var card = document.createElement("button"); + card.className = "result-card"; + card.dataset.slug = result.slug; + card.dataset.index = i; + + var title = document.createElement("h3"); + title.innerHTML = highlightMatch(item.title || result.slug, term); + card.appendChild(title); + + if (item.description) { + var desc = document.createElement("p"); + desc.className = "card-description"; + desc.textContent = item.description; + card.appendChild(desc); + } + + card.addEventListener("click", function() { + window.location.href = "/" + this.dataset.slug; + }); + + card.addEventListener("mouseenter", function() { + currentIndex = parseInt(this.dataset.index); + updateFocus(); + if (previewContainer) { + showPreview(this.dataset.slug); + } + }); + + resultsContainer.appendChild(card); + currentResults.push(card); + } + } + + function showPreview(slug) { + removeAllChildren(previewContainer); + var item = fetchContent(slug); + if (!item) return; + + var inner = document.createElement("div"); + inner.className = "preview-inner"; + + var title = document.createElement("h1"); + title.textContent = item.title || slug; + inner.appendChild(title); + + if (item.content) { + var content = document.createElement("div"); + content.innerHTML = item.content.substring(0, 2000); + inner.appendChild(content); + } + + previewContainer.appendChild(inner); + } + + function updateFocus() { + for (var i = 0; i < currentResults.length; i++) { + if (i === currentIndex) { + currentResults[i].classList.add("focus"); + } else { + currentResults[i].classList.remove("focus"); + } + } + } + + function performSearch(term) { + if (!term || term.trim() === "") { + searchLayout.classList.remove("display-results"); + return; + } + + var results = index.search(term, { limit: numSearchResults, enrich: true }); + var flatResults = []; + + for (var i = 0; i < results.length; i++) { + var fieldResults = results[i].result; + for (var j = 0; j < fieldResults.length; j++) { + var doc = fieldResults[j].doc; + flatResults.push(doc); + } + } + + searchLayout.classList.add("display-results"); + displayResults(flatResults, term); + } + + function openSearch() { + searchContainer.classList.add("active"); + document.documentElement.classList.add("search-open"); + if (searchBar) { + searchBar.focus(); + searchBar.value = ""; + } + currentIndex = -1; + } + + function closeSearch() { + searchContainer.classList.remove("active"); + document.documentElement.classList.remove("search-open"); + if (searchBar) { + searchBar.value = ""; + searchBar.blur(); + } + searchLayout.classList.remove("display-results"); + currentIndex = -1; + } + + function setupSearch() { + var searchButtons = document.querySelectorAll(".search-button"); + for (var i = 0; i < searchButtons.length; i++) { + searchButtons[i].addEventListener("click", openSearch); + } + + searchContainer = document.querySelector(".search-container"); + if (!searchContainer) return; + + searchBar = searchContainer.querySelector(".search-bar"); + searchLayout = searchContainer.querySelector(".search-layout"); + resultsContainer = searchContainer.querySelector(".results-container"); + previewContainer = searchContainer.querySelector(".preview-container"); + + if (searchBar) { + searchBar.addEventListener("input", function(e) { + performSearch(e.target.value); + }); + + searchBar.addEventListener("keydown", function(e) { + if (e.key === "Escape") { + closeSearch(); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + currentIndex = Math.min(currentIndex + 1, currentResults.length - 1); + updateFocus(); + if (currentIndex >= 0 && previewContainer) { + showPreview(currentResults[currentIndex].dataset.slug); + } + } else if (e.key === "ArrowUp") { + e.preventDefault(); + currentIndex = Math.max(currentIndex - 1, -1); + updateFocus(); + if (currentIndex >= 0 && previewContainer) { + showPreview(currentResults[currentIndex].dataset.slug); + } + } else if (e.key === "Enter") { + e.preventDefault(); + if (currentIndex >= 0 && currentResults[currentIndex]) { + window.location.href = "/" + currentResults[currentIndex].dataset.slug; + } + } + }); + } + + document.addEventListener("click", function(e) { + if (e.target.closest(".search-space")) return; + closeSearch(); + }); + + document.addEventListener("keydown", function(e) { + if ((e.key === "k" || e.key === "K") && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + if (searchContainer.classList.contains("active")) { + closeSearch(); + } else { + openSearch(); + } + } + }); + } + + initIndex().then(setupSearch); + } +})(); +`; + +export const Search = (userOpts?: Partial) => { + const opts = { ...defaultOptions, ...userOpts }; + + const SearchComponent: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => { + const locale = cfg?.locale || "en-US"; + + const translations: Record = { + "ar-SA": { title: "بحث", placeholder: "ابحث عن شيء" }, + "ca-ES": { title: "Cerca", placeholder: "Cerca quelcom" }, + "cs-CZ": { title: "Hledat", placeholder: "Hledat" }, + "de-DE": { title: "Suche", placeholder: "Suche nach etwas" }, + "en-GB": { title: "Search", placeholder: "Search for something" }, + "en-US": { title: "Search", placeholder: "Search for something" }, + "es-ES": { title: "Buscar", placeholder: "Buscar algo" }, + "fa-IR": { title: "جستجو", placeholder: "جستجو برای چیزی" }, + "fi-FI": { title: "Haku", placeholder: "Hae jotain" }, + "fr-FR": { title: "Recherche", placeholder: "Rechercher quelque chose" }, + "he-IL": { title: "חיפוש", placeholder: "חפש משהו" }, + "hu-HU": { title: "Keresés", placeholder: "Keress valamit" }, + "id-ID": { title: "Cari", placeholder: "Cari sesuatu" }, + "it-IT": { title: "Cerca", placeholder: "Cerca qualcosa" }, + "ja-JP": { title: "検索", placeholder: "何かを検索" }, + "kk-KZ": { title: "Іздеу", placeholder: "Бір нәрсе іздеу" }, + "ko-KR": { title: "검색", placeholder: "무언가를 검색" }, + "lt-LT": { title: "Paieška", placeholder: "Ieškoti kažko" }, + "nb-NO": { title: "Søk", placeholder: "Søk etter noe" }, + "nl-NL": { title: "Zoeken", placeholder: "Zoek naar iets" }, + "pl-PL": { title: "Szukaj", placeholder: "Szukaj czegoś" }, + "pt-BR": { title: "Buscar", placeholder: "Buscar algo" }, + "ro-RO": { title: "Căutare", placeholder: "Caută ceva" }, + "ru-RU": { title: "Поиск", placeholder: "Искать что-то" }, + "th-TH": { title: "ค้นหา", placeholder: "ค้นหาบางอย่าง" }, + "tr-TR": { title: "Ara", placeholder: "Bir şey ara" }, + "uk-UA": { title: "Пошук", placeholder: "Шукати щось" }, + "vi-VN": { title: "Tìm kiếm", placeholder: "Tìm kiếm thứ gì đó" }, + "zh-CN": { title: "搜索", placeholder: "搜索内容" }, + "zh-TW": { title: "搜尋", placeholder: "搜尋內容" }, + }; + + const t = translations[locale] || + translations["en-US"] || { title: "Search", placeholder: "Search for something" }; + const enablePreview = opts.enablePreview ?? true; + + return ( +
+ +
+
+ +
+
+ {enablePreview &&
} +
+
+
+
+ ); + }; + + SearchComponent.css = searchCSS; + SearchComponent.afterDOMLoaded = searchScript; + + return SearchComponent; +}; + +export default Search; diff --git a/src/components/index.ts b/src/components/index.ts index abecb6a..81247f7 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1 +1 @@ -export { ExampleComponent, type ExampleComponentOptions } from "./ExampleComponent"; +export { Search, type SearchOptions } from "./Search"; diff --git a/src/emitter.ts b/src/emitter.ts deleted file mode 100644 index 702523c..0000000 --- a/src/emitter.ts +++ /dev/null @@ -1,88 +0,0 @@ -import path from "node:path"; -import fs from "node:fs/promises"; -import type { QuartzEmitterPlugin } from "@jackyzha0/quartz/plugins/types"; -import type { ProcessedContent } from "@jackyzha0/quartz/plugins/vfile"; -import type { BuildCtx } from "@jackyzha0/quartz/util/ctx"; -import type { FilePath, FullSlug } from "@jackyzha0/quartz/util/path"; -import type { ExampleEmitterOptions } from "./types"; - -const defaultOptions: ExampleEmitterOptions = { - manifestSlug: "plugin-manifest", - includeFrontmatter: true, - metadata: { - generator: "Quartz Plugin Template", - }, -}; - -const joinSegments = (...segments: string[]) => - segments - .filter((segment) => segment.length > 0) - .join("/") - .replace(/\/+/g, "/") as FilePath; - -const writeFile = async ( - outputDir: string, - slug: FullSlug, - ext: `.${string}` | "", - content: string, -) => { - const outputPath = joinSegments(outputDir, `${slug}${ext}`) as FilePath; - await fs.mkdir(path.dirname(outputPath), { recursive: true }); - await fs.writeFile(outputPath, content); - return outputPath; -}; - -/** - * Example emitter that writes a JSON manifest of content metadata. - */ -export const ExampleEmitter: QuartzEmitterPlugin> = ( - userOptions?: Partial, -) => { - const options = { ...defaultOptions, ...userOptions }; - const emitManifest = async (ctx: BuildCtx, content: ProcessedContent[]) => { - const manifest = { - ...options.metadata, - generatedAt: new Date().toISOString(), - pages: content.map(([_tree, vfile]) => { - const frontmatter = (vfile.data?.frontmatter ?? {}) as { - title?: string; - tags?: string[]; - [key: string]: unknown; - }; - return { - slug: vfile.data?.slug ?? null, - title: frontmatter.title ?? null, - tags: frontmatter.tags ?? null, - filePath: vfile.data?.filePath ?? null, - frontmatter: options.includeFrontmatter ? frontmatter : undefined, - }; - }), - }; - - let json = `${JSON.stringify(manifest, null, 2)}\n`; - if (options.transformManifest) { - json = options.transformManifest(json); - } - - const output = await writeFile( - ctx.argv.output, - options.manifestSlug as FullSlug, - ".json", - json, - ); - return [output]; - }; - - return { - name: "ExampleEmitter", - async emit(ctx, content, _resources) { - return emitManifest(ctx, content); - }, - async *partialEmit(ctx, content, _resources, _changeEvents) { - const outputPaths = await emitManifest(ctx, content); - for (const outputPath of outputPaths) { - yield outputPath; - } - }, - }; -}; diff --git a/src/filter.ts b/src/filter.ts deleted file mode 100644 index 38c4d8a..0000000 --- a/src/filter.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { QuartzFilterPlugin } from "@jackyzha0/quartz/plugins/types"; -import type { ProcessedContent } from "@jackyzha0/quartz/plugins/vfile"; -import type { BuildCtx } from "@jackyzha0/quartz/util/ctx"; -import type { ExampleFilterOptions } from "./types"; - -const defaultOptions: ExampleFilterOptions = { - allowDrafts: false, - excludeTags: ["private"], - excludePathPrefixes: ["_drafts/", "_private/"], -}; - -const normalizeTag = (tag: unknown) => (typeof tag === "string" ? tag.trim().toLowerCase() : ""); - -const includesTag = (tags: unknown, excludedTags: string[]) => { - if (!Array.isArray(tags)) { - return false; - } - - const normalizedExcluded = excludedTags.map((tag) => tag.toLowerCase()); - return tags.some((tag) => normalizedExcluded.includes(normalizeTag(tag))); -}; - -/** - * Example filter that removes drafts, tagged pages, and excluded path prefixes. - */ -export const ExampleFilter: QuartzFilterPlugin> = ( - userOptions?: Partial, -) => { - const options = { ...defaultOptions, ...userOptions }; - return { - name: "ExampleFilter", - shouldPublish(_ctx: BuildCtx, [_tree, vfile]: ProcessedContent) { - const frontmatter = (vfile.data?.frontmatter ?? {}) as { - draft?: boolean | string; - tags?: string[]; - }; - const isDraft = frontmatter.draft === true || frontmatter.draft === "true"; - if (isDraft && !options.allowDrafts) { - return false; - } - - if (includesTag(frontmatter.tags, options.excludeTags)) { - return false; - } - - const filePath = typeof vfile.data?.filePath === "string" ? vfile.data.filePath : ""; - const normalizedPath = filePath.replace(/\\/g, "/"); - if (options.excludePathPrefixes.some((prefix) => normalizedPath.startsWith(prefix))) { - return false; - } - - return true; - }, - }; -}; diff --git a/src/index.ts b/src/index.ts index e97b5ff..e919b47 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,3 @@ -export { ExampleTransformer } from "./transformer"; -export { ExampleFilter } from "./filter"; -export { ExampleEmitter } from "./emitter"; -export { ExampleComponent } from "./components/ExampleComponent"; +export { Search } from "./components/Search"; -export type { - ExampleTransformerOptions, - ExampleFilterOptions, - ExampleEmitterOptions, - ExampleComponentOptions, -} from "./types"; +export type { SearchOptions } from "./components/Search"; diff --git a/src/transformer.ts b/src/transformer.ts deleted file mode 100644 index 4fb8207..0000000 --- a/src/transformer.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { PluggableList, Plugin } from "unified"; -import type { Root as MdastRoot } from "mdast"; -import type { Root as HastRoot, Element } from "hast"; -import type { VFile } from "vfile"; -import remarkGfm from "remark-gfm"; -import rehypeSlug from "rehype-slug"; -import { findAndReplace } from "mdast-util-find-and-replace"; -import { visit } from "unist-util-visit"; -import type { QuartzTransformerPlugin } from "@jackyzha0/quartz/plugins/types"; -import type { BuildCtx } from "@jackyzha0/quartz/util/ctx"; -import type { ExampleTransformerOptions } from "./types"; - -const defaultOptions: ExampleTransformerOptions = { - highlightToken: "==", - headingClass: "example-plugin-heading", - enableGfm: true, - addHeadingSlugs: true, -}; - -const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - -const remarkHighlightToken = (token: string): Plugin<[], MdastRoot> => { - const escapedToken = escapeRegExp(token); - const pattern = new RegExp(`${escapedToken}([^\n]+?)${escapedToken}`, "g"); - return () => (tree: MdastRoot, _file: VFile) => { - findAndReplace(tree, [ - [ - pattern, - (_match: string, value: string) => ({ - type: "strong", - children: [{ type: "text", value }], - }), - ], - ]); - }; -}; - -const rehypeHeadingClass = (className: string): Plugin<[], HastRoot> => { - return () => (tree: HastRoot, _file: VFile) => { - visit(tree, "element", (node: Element) => { - if (!/^h[1-6]$/.test(node.tagName)) { - return; - } - - const existing = node.properties?.className; - const classes: string[] = Array.isArray(existing) - ? existing.filter((value): value is string => typeof value === "string") - : typeof existing === "string" - ? [existing] - : []; - node.properties = { - ...node.properties, - className: [...classes, className], - }; - }); - }; -}; - -/** - * Example transformer showing remark/rehype usage and resource injection. - */ -export const ExampleTransformer: QuartzTransformerPlugin> = ( - userOptions?: Partial, -) => { - const options = { ...defaultOptions, ...userOptions }; - return { - name: "ExampleTransformer", - textTransform(_ctx: BuildCtx, src: string) { - return src.endsWith("\n") ? src : `${src}\n`; - }, - markdownPlugins(): PluggableList { - const plugins: PluggableList = [remarkHighlightToken(options.highlightToken)]; - if (options.enableGfm) { - plugins.unshift(remarkGfm); - } - return plugins; - }, - htmlPlugins(): PluggableList { - const plugins: PluggableList = [rehypeHeadingClass(options.headingClass)]; - if (options.addHeadingSlugs) { - plugins.unshift(rehypeSlug); - } - return plugins; - }, - externalResources() { - return { - css: [ - { - content: `.${options.headingClass} { letter-spacing: 0.02em; }`, - inline: true, - }, - ], - js: [ - { - contentType: "inline", - loadTime: "afterDOMReady", - script: "document.documentElement.dataset.exampleTransformer = 'true'", - }, - ], - additionalHead: [], - }; - }, - }; -}; diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index f20f880..0000000 --- a/src/types.ts +++ /dev/null @@ -1,54 +0,0 @@ -export type { - ChangeEvent, - QuartzEmitterPlugin, - QuartzEmitterPluginInstance, - QuartzFilterPlugin, - QuartzFilterPluginInstance, - QuartzTransformerPlugin, - QuartzTransformerPluginInstance, -} from "@jackyzha0/quartz/plugins/types"; -export type { ProcessedContent, QuartzPluginData } from "@jackyzha0/quartz/plugins/vfile"; -export type { BuildCtx } from "@jackyzha0/quartz/util/ctx"; -export type { CSSResource, JSResource, StaticResources } from "@jackyzha0/quartz/util/resources"; - -export interface ExampleTransformerOptions { - /** Token used to highlight text, defaults to ==highlight== */ - highlightToken: string; - /** Add a CSS class to all headings in the rendered HTML. */ - headingClass: string; - /** Enable remark-gfm for tables/task lists. */ - enableGfm: boolean; - /** Enable adding slug IDs to headings. */ - addHeadingSlugs: boolean; -} - -export interface ExampleFilterOptions { - /** Allow pages marked draft: true to publish. */ - allowDrafts: boolean; - /** Exclude pages that contain any of these frontmatter tags. */ - excludeTags: string[]; - /** Exclude paths that start with any of these prefixes (relative to content root). */ - excludePathPrefixes: string[]; -} - -export interface ExampleEmitterOptions { - /** Filename to emit at the site root. */ - manifestSlug: string; - /** Whether to include the frontmatter block in the manifest. */ - includeFrontmatter: boolean; - /** Extra metadata to write at the top level of the manifest. */ - metadata: Record; - /** Optional hook to transform the emitted manifest JSON string. */ - transformManifest?: (json: string) => string; - /** Add a custom class to the emitted manifest