feat: port Search component from Quartz

This commit is contained in:
saberzero1 2026-02-07 07:37:21 +01:00
parent 8da96c0b6a
commit 0950f7f49e
No known key found for this signature in database
12 changed files with 783 additions and 535 deletions

284
README.md
View file

@ -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<string, unknown>` | `{ 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

4
package-lock.json generated
View file

@ -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": {

View file

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

View file

@ -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 <div class={className}>{fullText}</div>;
};
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;

638
src/components/Search.tsx Normal file
View file

@ -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, '<span class="highlight">$1</span>');
}
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<SearchOptions>) => {
const opts = { ...defaultOptions, ...userOpts };
const SearchComponent: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
const locale = cfg?.locale || "en-US";
const translations: Record<string, { title: string; placeholder: string }> = {
"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 (
<div class={classNames(displayClass, "search")}>
<button class="search-button">
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 19.9 19.7">
<title>{t.title}</title>
<g class="search-path" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="square" d="M18.5 18.3l-5.4-5.4" />
<circle cx="8" cy="8" r="7" />
</g>
</svg>
<p>{t.title}</p>
</button>
<div class="search-container">
<div class="search-space">
<input
autocomplete="off"
class="search-bar"
name="search"
type="text"
aria-label={t.placeholder}
placeholder={t.placeholder}
/>
<div class="search-layout" data-preview={enablePreview}>
<div class="results-container"></div>
{enablePreview && <div class="preview-container"></div>}
</div>
</div>
</div>
</div>
);
};
SearchComponent.css = searchCSS;
SearchComponent.afterDOMLoaded = searchScript;
return SearchComponent;
};
export default Search;

View file

@ -1 +1 @@
export { ExampleComponent, type ExampleComponentOptions } from "./ExampleComponent";
export { Search, type SearchOptions } from "./Search";

View file

@ -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<Partial<ExampleEmitterOptions>> = (
userOptions?: Partial<ExampleEmitterOptions>,
) => {
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;
}
},
};
};

View file

@ -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<Partial<ExampleFilterOptions>> = (
userOptions?: Partial<ExampleFilterOptions>,
) => {
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;
},
};
};

View file

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

View file

@ -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<Partial<ExampleTransformerOptions>> = (
userOptions?: Partial<ExampleTransformerOptions>,
) => {
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: [],
};
},
};
};

View file

@ -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<string, unknown>;
/** Optional hook to transform the emitted manifest JSON string. */
transformManifest?: (json: string) => string;
/** Add a custom class to the emitted manifest <script> tag if used in HTML. */
manifestScriptClass?: string;
}
export interface ExampleComponentOptions {
/** Text to prefix before the title */
prefix?: string;
/** Text to suffix after the title */
suffix?: string;
/** CSS class name to apply */
className?: string;
}

View file

@ -3,7 +3,6 @@ import { defineConfig } from "tsup";
export default defineConfig({
entry: {
index: "src/index.ts",
types: "src/types.ts",
"components/index": "src/components/index.ts",
},
format: ["esm"],