mirror of
https://github.com/quartz-community/search.git
synced 2026-07-22 02:50:25 +00:00
feat: port Search component from Quartz
This commit is contained in:
parent
8da96c0b6a
commit
0950f7f49e
12 changed files with 783 additions and 535 deletions
284
README.md
284
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<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
4
package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
17
package.json
17
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",
|
||||
|
|
|
|||
|
|
@ -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
638
src/components/Search.tsx
Normal 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;
|
||||
|
|
@ -1 +1 @@
|
|||
export { ExampleComponent, type ExampleComponentOptions } from "./ExampleComponent";
|
||||
export { Search, type SearchOptions } from "./Search";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
};
|
||||
12
src/index.ts
12
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";
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
54
src/types.ts
54
src/types.ts
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"],
|
||||
|
|
|
|||
Loading…
Reference in a new issue