Initial commit

This commit is contained in:
Emile Bangma 2026-06-10 16:46:04 +02:00 committed by GitHub
commit a5aa9d744d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 11262 additions and 0 deletions

41
.eslintrc.json Normal file
View file

@ -0,0 +1,41 @@
{
"root": true,
"env": {
"es2022": true,
"node": true
},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"sourceType": "module",
"ecmaVersion": "latest",
"project": "./tsconfig.json"
},
"plugins": ["@typescript-eslint"],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
"ignorePatterns": ["dist", "node_modules"],
"rules": {
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_"
}
],
"no-restricted-syntax": [
"warn",
{
"selector": "Program:has(CallExpression[callee.property.name='addEventListener']):not(:has(CallExpression[callee.object.name='window'][callee.property.name='addCleanup']))",
"message": "addEventListener should be paired with window.addCleanup() for proper SPA cleanup. See ARCHITECTURE.md for details."
}
]
},
"overrides": [
{
"files": ["types/**/*.d.ts"],
"rules": {
"@typescript-eslint/triple-slash-reference": "off"
}
}
]
}

96
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,96 @@
name: CI
on:
push:
branches: ["main"]
tags: ["v*"]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run checks
run: npm run check
- name: Build
run: npm run build
- name: Verify dist is up to date
run: |
if git diff --quiet dist/; then
echo "dist/ is up to date"
else
echo "::error::dist/ is stale. Run 'npm run build' and commit the result."
git diff --stat dist/
exit 1
fi
- name: Verify dist externals
run: |
node -e "
const fs = require('fs');
const dist = 'dist/index.js';
if (!fs.existsSync(dist)) process.exit(0);
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
const peerDeps = Object.keys(pkg.peerDependencies || {});
const builtins = new Set(['assert','buffer','child_process','cluster','console','constants','crypto','dgram','dns','domain','events','fs','http','http2','https','inspector','module','net','os','path','perf_hooks','process','punycode','querystring','readline','repl','stream','string_decoder','sys','timers','tls','trace_events','tty','url','util','v8','vm','wasi','worker_threads','zlib']);
const shared = ['@quartz-community/','preact','@jackyzha0/quartz','vfile','unified'];
const content = fs.readFileSync(dist, 'utf-8');
const re = /^\s*(?:import\s+.*\s+from|export\s+.*\s+from)\s+[\"']([^\"'./][^\"']*)[\"']/gm;
const unexpected = new Set();
for (const m of content.matchAll(re)) {
const s = m[1];
if (s.startsWith('node:')) continue;
const bare = s.includes('/') && s.startsWith('@') ? s.split('/').slice(0,2).join('/') : s.split('/')[0];
if (builtins.has(bare)) continue;
if (shared.some(p => s.startsWith(p))) continue;
if (peerDeps.some(d => s === d || s.startsWith(d + '/'))) continue;
unexpected.add(s);
}
if (unexpected.size > 0) {
console.error('ERROR: dist/ has unbundled external imports that will fail at runtime:');
for (const u of unexpected) console.error(' - ' + u);
console.error('Move these from dependencies to devDependencies so tsup bundles them.');
process.exit(1);
}
console.log('dist/ externals verified: all imports are bundled or allowlisted.');
"
publish:
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
needs: [test]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
registry-url: "https://registry.npmjs.org"
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Publish
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

41
.gitignore vendored Normal file
View file

@ -0,0 +1,41 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Build output
build/
*.tsbuildinfo
!src/build/
!src/build/**
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Environment files
.env
.env.local
.env.*.local
# IDE/Editor files
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Testing
coverage/
.nyc_output
# Package files
*.tgz
# Cache
.cache/
.eslintcache

1
.prettierignore Normal file
View file

@ -0,0 +1 @@
dist

7
.prettierrc Normal file
View file

@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": false,
"printWidth": 100,
"trailingComma": "all",
"arrowParens": "always"
}

94
AGENTS.md Normal file
View file

@ -0,0 +1,94 @@
# Quartz Community Plugin Template
Provider-agnostic instruction file for AI coding assistants developing Quartz community plugins.
## Project Overview
This repository is a template for building, testing, and publishing Quartz community plugins. It uses a factory-function API where plugins are created by functions returning objects with a `name` and lifecycle hooks.
## Plugin Type Decision Tree
Plugins are not mutually exclusive. A single plugin can implement multiple types.
- **Transformer**: Modifies content during the build (remark/rehype). Use if you need to change how Markdown is parsed or rendered.
- **Filter**: Decides which files to include in the final site. Use for drafts, private notes, or path-based exclusions.
- **Emitter**: Generates new files (JSON, RSS, CNAME, etc.). Use for site-wide manifests or integration files.
- **Page Type**: Defines custom routes and page generation logic. Use for virtual pages or non-Markdown content.
- **Component**: Provides UI elements for Quartz layouts. Use for navigation, sidebars, or custom widgets.
- **Bases View**: Registers custom views in the `@quartz-community/bases-page` system.
## Files to Modify
- `src/`: All plugin logic, components, and styles.
- `package.json`: Plugin manifest (`quartz` field), dependencies, and metadata.
- `src/i18n/`: Translations for multi-language support.
## Leave Alone
- `dist/`: Build output. Committed to repo for pre-built distribution. Regenerated by `npm run build`.
- `.github/`: CI/CD workflows (unless customizing publishing).
- `tsup.config.ts`: Build configuration. Defines SINGLETON_EXTERNALS and bundling strategy. Only modify to add native dep exclusions.
## Plugin Creation Workflow
1. **Define Options**: Create an interface for plugin configuration in `src/types.ts`.
2. **Implement Logic**: Create the plugin factory in a new file (e.g., `src/my-plugin.ts`).
3. **Export**: Add the plugin to `src/index.ts`.
4. **Manifest**: Update the `quartz` field in `package.json` with category and default options.
5. **Test**: Add a test case in `src/tests/` and run `npm test`.
6. **Build and commit**: Run `npm run build` and commit the `dist/` output.
## Package.json Quartz Manifest
The `quartz` field is required for discovery and configuration:
```json
{
"quartz": {
"name": "my-plugin",
"category": ["transformer", "component"],
"defaultOptions": { "enabled": true },
"optionSchema": { "enabled": { "type": "boolean" } },
"components": { "MyComponent": { "defaultPosition": "right" } }
}
}
```
## Import Patterns
- **Types**: Import from `@quartz-community/types`.
- **Utils**: Import from `@quartz-community/utils`.
- **Runtime**: Use `vfile` for content manipulation in transformers.
## i18n Setup
1. Add keys to `src/i18n/locales/en-US.ts`.
2. Create other locales in `src/i18n/locales/`.
3. Use the `i18n` helper in your plugin or component.
## Common Mistakes
- **Missing Exports**: Forgetting to export the plugin factory from `src/index.ts`.
- **Wrong Category**: Not matching the `category` in `package.json` with the implemented hooks.
- **Peer Dependencies**: Adding `preact` or `vfile` as `dependencies` instead of `peerDependencies`.
- **Gitignoring dist/**: The `dist/` directory must be committed for pre-built distribution.
- **Bundling native deps**: Plugins using `sharp`, `@napi-rs/*`, or other NAPI packages must exclude them from `noExternal`.
## Testing Patterns
Use `vitest`. Mock the `BuildCtx` and `ProcessedContent` when testing transformers or emitters.
## Checklist Before Submission
- [ ] `npm run build` completes without errors.
- [ ] `npm test` passes all cases.
- [ ] `dist/` is committed and up to date (CI verifies this).
- [ ] `package.json` manifest is complete and accurate.
- [ ] `README.md` documents all options.
## Build System Quirks
- **SINGLETON_EXTERNALS**: `SINGLETON_EXTERNALS` in `tsup.config.ts` defines packages that must NOT be bundled — they must be the same instance across all plugins (`preact`, `vfile`, `unified`, `@jackyzha0/quartz`). Everything else is bundled into `dist/`.
- **.inline.ts**: Files ending in `.inline.ts` are bundled as raw strings for client-side injection.
- **.scss**: Styles are compiled to CSS strings and attached to components via `Component.css`.
- **Branded Types**: Use `FullSlug` and `FilePath` from `@quartz-community/types` for path safety.

73
ARCHITECTURE.md Normal file
View file

@ -0,0 +1,73 @@
# Architecture Reference
Machine-readable architecture overview for the Quartz community plugin template.
## Plugin Lifecycle
Quartz plugins are factory functions that return an object with a `name` and lifecycle hooks.
1. **Loading**: Quartz imports the plugin from the `externalPlugins` list in `quartz.config.ts`.
2. **Initialization**: The factory function is called with user-provided options.
3. **Build Integration**:
- **Transformers**: Run during the `transform` phase (remark/rehype).
- **Filters**: Run after transformation to prune the content list.
- **Emitters**: Run during the `emit` phase to generate files.
- **Page Types**: Run to generate virtual pages or custom routes.
- **Components**: Rendered during the HTML generation phase.
## Build System
The template uses `tsup` for bundling and declaration output.
- **Inline Scripts**: Files ending in `.inline.ts` are bundled as raw strings for client-side injection.
- **Styles**: `.scss` files are compiled to CSS strings and attached to components via `Component.css`.
- **Entry Points**:
- `index.ts`: Main plugin exports.
- `types.ts`: Shared type definitions.
- `components/index.ts`: UI component exports.
## Type System
The template relies on `@quartz-community/types` for core Quartz types.
- **Branded Types**: `FullSlug` and `FilePath` are used for path safety.
- **vfile DataMap**: Augmented with `QuartzPluginData` for plugin-specific metadata.
- **Plugin Interfaces**: `QuartzTransformerPlugin`, `QuartzFilterPlugin`, `QuartzEmitterPlugin`, `QuartzPageTypePlugin`, `QuartzComponentConstructor`.
## Export Conventions
- **Default Exports**: Used for components.
- **Named Exports**: Used for plugin factories and types.
- **Re-exports**: `src/index.ts` re-exports all public APIs.
## Dependency Management
- **peerDependencies**: `preact`, `vfile`, and `@jackyzha0/quartz` (optional).
- **dependencies**: `@quartz-community/types`, `@quartz-community/utils`.
- **devDependencies**: Build tools, linters, and test runners.
## Testing Infrastructure
- **vitest**: Test runner.
- **test helpers**: Mock `BuildCtx` and `ProcessedContent` for plugin testing.
## CI/CD Pipeline
- **GitHub Actions**: Runs `npm run check` on PRs and publishes to npm on version tags (`v*`).
## Directory Structure
- `src/`: Source code.
- `components/`: UI components.
- `scripts/`: Client-side scripts (`.inline.ts`).
- `styles/`: Component styles (`.scss`).
- `i18n/`: Internationalization.
- `util/`: Utility functions.
- `index.ts`: Main entry point.
- `types.ts`: Type definitions.
- `transformer.ts`: Transformer implementation.
- `filter.ts`: Filter implementation.
- `emitter.ts`: Emitter implementation.
- `dist/`: Build output.
- `package.json`: Manifest and dependencies.
- `tsup.config.ts`: Build configuration.

12
CHANGELOG.md Normal file
View file

@ -0,0 +1,12 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Initial Quartz community plugin template.

132
EXAMPLES.md Normal file
View file

@ -0,0 +1,132 @@
# Plugin Examples
Minimal, annotated examples for each Quartz plugin type.
## 1. Minimal Transformer
Wraps an existing remark plugin to enforce hard line breaks.
```ts
import remarkBreaks from "remark-breaks";
import type { QuartzTransformerPlugin } from "@quartz-community/types";
export const HardLineBreaks: QuartzTransformerPlugin<void> = () => ({
name: "HardLineBreaks",
markdownPlugins() {
// Return a list of unified/remark plugins
return [remarkBreaks];
},
});
```
## 2. Minimal Filter
Excludes pages marked as `draft: true` in frontmatter.
```ts
import type { QuartzFilterPlugin } from "@quartz-community/types";
export const RemoveDrafts: QuartzFilterPlugin<void> = () => ({
name: "RemoveDrafts",
shouldPublish(_ctx, [_tree, vfile]) {
// Access frontmatter from vfile data
const draft = vfile.data?.frontmatter?.draft;
// Return false to exclude the page from the build
return draft !== true;
},
});
```
## 3. Minimal Emitter
Writes a `CNAME` file to the output directory.
```ts
import fs from "node:fs/promises";
import path from "node:path";
import type { QuartzEmitterPlugin } from "@quartz-community/types";
export const CNAME: QuartzEmitterPlugin<{ domain: string }> = (opts) => ({
name: "CNAME",
async emit(ctx, _content, _resources) {
// ctx.argv.output is the destination directory
const filePath = path.join(ctx.argv.output, "CNAME");
await fs.writeFile(filePath, opts.domain);
// Return the list of emitted file paths
return [filePath as any];
},
});
```
## 4. Minimal Component
Renders a simple spacer div with custom CSS.
```tsx
import type { QuartzComponent, QuartzComponentConstructor } from "@quartz-community/types";
export default ((opts?: { height?: string }) => {
const Component: QuartzComponent = () => {
return <div class="spacer" style={{ height: opts?.height ?? "1rem" }} />;
};
// Attach CSS string to the component
Component.css = ".spacer { width: 100%; }";
return Component;
}) satisfies QuartzComponentConstructor;
```
## 5. Minimal Page Type
Generates a virtual "About" page if it doesn't exist.
```ts
import type { QuartzPageTypePlugin } from "@quartz-community/types";
export const AboutPage: QuartzPageTypePlugin<void> = () => ({
name: "AboutPage",
// Match the slug to handle
match: (slug) => slug === "about",
// Generate the page content
generate: async (_ctx, _content) => ({
slug: "about" as any,
frontmatter: { title: "About" },
content: "This is a virtual about page.",
}),
});
```
## 6. Minimal Bases View Registration
Registers a custom view for the `@quartz-community/bases-page` system.
```ts
import { viewRegistry } from "@quartz-community/bases-page";
export function init() {
// Register a view that can be used in bases-page layouts
viewRegistry.register("my-custom-view", (props) => {
return <div>Custom View for {props.fileData.slug}</div>;
});
}
```
## 7. Minimal i18n Setup
Per-plugin translations with a fallback mechanism.
```ts
// src/i18n/locales/en-US.ts
export default {
hello: "Hello",
};
// src/i18n/index.ts
import enUS from "./locales/en-US";
const locales = { "en-US": enUS };
export function i18n(locale: string) {
// Fallback to en-US if locale is not found
return locales[locale as keyof typeof locales] || enUS;
}
```

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Quartz Community
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

307
README.md Normal file
View file

@ -0,0 +1,307 @@
# Quartz Community Plugin Template
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.
## Highlights
- ✅ Quartz-compatible transformer/filter/emitter examples
- ✅ TypeScript-first with exported types for consumers
- ✅ `tsup` bundling + declaration output
- ✅ Pre-built `dist/` ships in the repo — instant installation for users
- ✅ 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
## Getting started
```bash
npm install
npm run build
```
> [!important]
> After building, the `dist/` directory should be committed to the repository. It is not gitignored, as Quartz uses it for pre-built distribution.
## Build and Distribution
The template is configured to bundle all dependencies by default via `noExternal: [/.*/]` in `tsup.config.ts`. This ensures that users don't need to install any dependencies when using your plugin.
- **Singleton Externals**: Certain packages (`preact`, `vfile`, `unified`, `@jackyzha0/quartz`) are kept external to ensure only one instance of them exists across all plugins.
- **Native Dependencies**: If your plugin uses native dependencies (like `sharp`, `@napi-rs/simple-git`, etc.), you must exclude them from bundling. Use a regex pattern in `noExternal` to exclude them, for example: `noExternal: [/^(?!sharp)/]`.
- **CI Verification**: The included CI workflow verifies that `dist/` is up to date on every push.
## Usage in Quartz
Install your plugin into a Quartz v5 site:
```bash
npx quartz plugin add github:quartz-community/plugin-template
```
Then register it in `quartz.config.yaml`:
```yaml
plugins:
- source: github:quartz-community/plugin-template
enabled: true
options:
highlightToken: "=="
```
If you need to use the plugin in `quartz.ts` for advanced overrides:
```ts
import * as ExternalPlugin from "./.quartz/plugins";
export default {
plugins: {
transformers: [ExternalPlugin.ExampleTransformer({ highlightToken: "==" })],
},
};
```
## 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 "@quartz-community/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.
## Component Plugins (UI Components)
In addition to transformer/filter/emitter plugins, you can create **component plugins** that provide
UI elements for Quartz layouts. See `src/components/ExampleComponent.tsx` for a reference.
### Component Pattern
```tsx
import type { QuartzComponent, QuartzComponentConstructor } from "@quartz-community/types";
import style from "./styles/example.scss";
import script from "./scripts/example.inline.ts";
export default ((opts?: MyComponentOptions) => {
const Component: QuartzComponent = (props) => {
return <div class="my-component">...</div>;
};
Component.css = style;
Component.afterDOMLoaded = script;
return Component;
}) satisfies QuartzComponentConstructor;
```
### Receiving YAML Options in Component-Only Plugins
Processing plugins (transformers, filters, emitters, page types) receive options automatically
through their factory function. **Component-only plugins** (those with `"category": ["component"]`)
are loaded via side-effect import and need an extra step to receive YAML options.
Export an `init` function from your plugin's entry point. Quartz's config-loader will call it with
the merged options from `package.json` `defaultOptions` and the user's `quartz.config.yaml`:
```ts
// src/index.ts
export function init(options?: Record<string, unknown>): void {
// Use the options to configure your plugin
const myOption = (options?.myOption as boolean) ?? false;
// e.g. register a view, set global state, etc.
}
```
Then declare default values in your `package.json` manifest:
```json
{
"quartz": {
"category": ["component"],
"defaultOptions": {
"myOption": false
}
}
}
```
Users configure options in `quartz.config.yaml`:
```yaml
plugins:
- source: github:your-username/my-component-plugin
enabled: true
options:
myOption: true
```
Quartz merges `defaultOptions` with the user's `options` (user values take precedence) and passes
the result to `init()`. If no `init` export exists, the plugin is loaded via side-effect import as
before — no breaking change for existing plugins.
### Client-Side Scripts
Component scripts run in the browser and must handle Quartz's SPA navigation. Key patterns:
1. **Use `@ts-nocheck`** - Client scripts run in a different context than build-time code
2. **Listen to `nav` event** - Fires after each page navigation (including initial load)
3. **Listen to `prenav` event** - Fires before navigation, use for saving state
4. **Use `window.addCleanup()`** - Register cleanup functions for event listeners
5. **Use `fetchData` global** - Access page metadata via the `fetchData` promise (handles base path correctly)
See `src/components/scripts/example.inline.ts` for a complete example with all patterns.
### Common Helper Functions
These utilities are commonly needed in component plugins:
```js
function removeAllChildren(element) {
while (element.firstChild) element.removeChild(element.firstChild);
}
function simplifySlug(slug) {
return slug.endsWith("/index") ? slug.slice(0, -6) : slug;
}
function getCurrentSlug() {
let slug = window.location.pathname;
if (slug.startsWith("/")) slug = slug.slice(1);
if (slug.endsWith("/")) slug = slug.slice(0, -1);
return slug || "index";
}
```
### State Persistence
Use `localStorage` for persistent state (survives browser close) and `sessionStorage` for
temporary state (like scroll positions):
```js
localStorage.setItem("myPlugin-state", JSON.stringify(state));
sessionStorage.setItem("myPlugin-scrollTop", element.scrollTop.toString());
```
## Migration Guide (from Quartz v4)
When migrating a v4 component to a standalone plugin:
1. **Replace Quartz imports** with `@quartz-community/types`
2. **Copy utility functions** (path helpers, DOM utils) into your plugin
3. **Use `@ts-nocheck`** for inline scripts that can't be type-checked
4. **Use the `fetchData` global** to access `contentIndex.json` with the correct base path
5. **Test with both local and production builds**
## License
MIT

10
dist/components/index.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
import { QuartzComponent } from '@quartz-community/types';
interface ExampleComponentOptions {
prefix?: string;
suffix?: string;
className?: string;
}
declare const _default: (opts?: ExampleComponentOptions) => QuartzComponent;
export { _default as ExampleComponent, type ExampleComponentOptions };

51
dist/components/index.js vendored Normal file
View file

@ -0,0 +1,51 @@
import { createRequire } from 'module';
createRequire(import.meta.url);
// node_modules/@quartz-community/utils/dist/lang.js
function classNames(...classes) {
return classes.filter(Boolean).join(" ");
}
// src/components/styles/example.scss
var example_default = ".example-component {\n padding: 8px 16px;\n background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);\n color: white;\n border-radius: 4px;\n font-weight: 600;\n display: inline-block;\n}";
// src/components/scripts/example.inline.ts
var example_inline_default = 'function l(){let e=window.location.pathname;return e.startsWith("/")&&(e=e.slice(1)),e.endsWith("/")&&(e=e.slice(0,-1)),e||"index"}function r(){let e=document.querySelectorAll(".example-component");if(e.length===0)return;let t=[];function o(n){(n.ctrlKey||n.metaKey)&&n.shiftKey&&n.key.toLowerCase()==="e"&&(n.preventDefault(),console.log("[ExampleComponent] Keyboard shortcut triggered!"))}document.addEventListener("keydown",o),t.push(()=>document.removeEventListener("keydown",o));for(let n of e){let i=()=>{console.log("[ExampleComponent] Clicked!")};n.addEventListener("click",i),t.push(()=>n.removeEventListener("click",i))}typeof window<"u"&&window.addCleanup&&window.addCleanup(()=>{t.forEach(n=>n())}),console.log("[ExampleComponent] Initialized with",e.length,"component(s)")}document.addEventListener("nav",e=>{let t=e.detail?.url||l();console.log("[ExampleComponent] Navigation to:",t),r()});document.addEventListener("render",()=>{console.log("[ExampleComponent] Render event - re-initializing"),r()});document.addEventListener("prenav",()=>{let e=document.querySelector(".example-component");e&&sessionStorage.setItem("exampleScrollTop",e.scrollTop?.toString()||"0")});\n';
var l;
l = { __e: function(n2, l2, u3, t2) {
for (var i2, o2, r2; l2 = l2.__; ) if ((i2 = l2.__c) && !i2.__) try {
if ((o2 = i2.constructor) && null != o2.getDerivedStateFromError && (i2.setState(o2.getDerivedStateFromError(n2)), r2 = i2.__d), null != i2.componentDidCatch && (i2.componentDidCatch(n2, t2 || {}), r2 = i2.__d), r2) return i2.__E = i2;
} catch (l3) {
n2 = l3;
}
throw n2;
} }, "function" == typeof Promise ? Promise.prototype.then.bind(Promise.resolve()) : setTimeout;
// node_modules/preact/jsx-runtime/dist/jsxRuntime.mjs
var f2 = 0;
function u2(e2, t2, n2, o2, i2, u3) {
t2 || (t2 = {});
var a2, c2, p2 = t2;
if ("ref" in p2) for (c2 in p2 = {}, t2) "ref" == c2 ? a2 = t2[c2] : p2[c2] = t2[c2];
var l2 = { type: e2, props: p2, key: n2, ref: a2, __k: null, __: null, __b: 0, __e: null, __c: null, constructor: void 0, __v: --f2, __i: -1, __u: 0, __source: i2, __self: u3 };
return l.vnode && l.vnode(l2), l2;
}
// src/components/ExampleComponent.tsx
var ExampleComponent_default = ((opts) => {
const { prefix = "", suffix = "", className = "example-component" } = opts ?? {};
const Component = (props) => {
const frontmatter = props.fileData?.frontmatter;
const title = frontmatter?.title ?? "Untitled";
const fullText = `${prefix}${title}${suffix}`;
return /* @__PURE__ */ u2("div", { class: classNames(className), children: fullText });
};
Component.css = example_default;
Component.afterDOMLoaded = example_inline_default;
return Component;
});
export { ExampleComponent_default as ExampleComponent };
//# sourceMappingURL=index.js.map
//# sourceMappingURL=index.js.map

1
dist/components/index.js.map vendored Normal file

File diff suppressed because one or more lines are too long

21
dist/index.d.ts vendored Normal file
View file

@ -0,0 +1,21 @@
import { QuartzTransformerPlugin, QuartzFilterPlugin, QuartzEmitterPlugin } from '@quartz-community/types';
export { PageGenerator, PageMatcher, QuartzComponent, QuartzComponentConstructor, QuartzComponentProps, QuartzEmitterPlugin, QuartzFilterPlugin, QuartzPageTypePlugin, QuartzPageTypePluginInstance, QuartzTransformerPlugin, StringResource, VirtualPage } from '@quartz-community/types';
import { ExampleTransformerOptions, ExampleFilterOptions, ExampleEmitterOptions } from './types.js';
export { ExampleComponent, ExampleComponentOptions } from './components/index.js';
/**
* Example transformer showing remark/rehype usage and resource injection.
*/
declare const ExampleTransformer: QuartzTransformerPlugin<Partial<ExampleTransformerOptions>>;
/**
* Example filter that removes drafts, tagged pages, and excluded path prefixes.
*/
declare const ExampleFilter: QuartzFilterPlugin<Partial<ExampleFilterOptions>>;
/**
* Example emitter that writes a JSON manifest of content metadata.
*/
declare const ExampleEmitter: QuartzEmitterPlugin<Partial<ExampleEmitterOptions>>;
export { ExampleEmitter, ExampleEmitterOptions, ExampleFilter, ExampleFilterOptions, ExampleTransformer, ExampleTransformerOptions };

3779
dist/index.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/index.js.map vendored Normal file

File diff suppressed because one or more lines are too long

42
dist/types.d.ts vendored Normal file
View file

@ -0,0 +1,42 @@
export { BuildCtx, CSSResource, ChangeEvent, JSResource, PageGenerator, PageMatcher, ProcessedContent, QuartzEmitterPlugin, QuartzEmitterPluginInstance, QuartzFilterPlugin, QuartzFilterPluginInstance, QuartzPageTypePlugin, QuartzPageTypePluginInstance, QuartzPluginData, QuartzTransformerPlugin, QuartzTransformerPluginInstance, StaticResources, VirtualPage } from '@quartz-community/types';
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;
}
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[];
}
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;
}
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;
}
export type { ExampleComponentOptions, ExampleEmitterOptions, ExampleFilterOptions, ExampleTransformerOptions };

5
dist/types.js vendored Normal file
View file

@ -0,0 +1,5 @@
import { createRequire } from 'module';
createRequire(import.meta.url);
//# sourceMappingURL=types.js.map
//# sourceMappingURL=types.js.map

1
dist/types.js.map vendored Normal file
View file

@ -0,0 +1 @@
{"version":3,"sources":[],"names":[],"mappings":"","file":"types.js"}

5539
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

128
package.json Normal file
View file

@ -0,0 +1,128 @@
{
"name": "@quartz-community/plugin-template",
"version": "0.1.0",
"description": "Template repository for Quartz community plugins.",
"type": "module",
"license": "MIT",
"author": "Quartz Community",
"homepage": "https://quartz.jzhao.xyz",
"repository": {
"type": "git",
"url": "https://github.com/quartz-community/plugin-template"
},
"keywords": [
"quartz",
"quartz-plugin",
"plugin-template",
"remark",
"rehype",
"mdast",
"hast"
],
"files": [
"dist",
"README.md",
"LICENSE",
"CHANGELOG.md"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./types": {
"types": "./dist/types.d.ts",
"import": "./dist/types.js"
},
"./components": {
"types": "./dist/components/index.d.ts",
"import": "./dist/components/index.js"
},
"./package.json": "./package.json"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"sideEffects": false,
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"lint": "eslint . --max-warnings=0",
"format": "prettier . --check",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"check": "npm run typecheck && npm run lint && npm run format && npm run test"
},
"peerDependencies": {
"preact": "^10.0.0",
"vfile": "^6.0.3"
},
"peerDependenciesMeta": {
"preact": {
"optional": false
},
"vfile": {
"optional": true
}
},
"dependencies": {
"@quartz-community/types": "github:quartz-community/types",
"@quartz-community/utils": "github:quartz-community/utils"
},
"devDependencies": {
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.4",
"@types/node": "^24.10.0",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"github-slugger": "^2.0.0",
"hast-util-to-jsx-runtime": "^2.3.6",
"mdast-util-find-and-replace": "^3.0.2",
"preact": "^10.28.2",
"prettier": "^3.6.2",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"sass": "^1.97.3",
"tsup": "^8.5.0",
"typescript": "^5.9.3",
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0",
"vfile": "^6.0.3",
"vitest": "^2.1.9"
},
"quartz": {
"name": "plugin-template",
"displayName": "Plugin Template",
"category": "transformer",
"version": "1.0.0",
"quartzVersion": ">=5.0.0",
"dependencies": [],
"defaultOrder": 50,
"defaultEnabled": true,
"defaultOptions": {},
"optionSchema": {
"highlightToken": {
"type": "enum",
"values": [
"==",
"**",
"~~"
]
}
},
"components": {
"ExampleComponent": {
"displayName": "Example Component",
"defaultPosition": "right",
"defaultPriority": 50
}
}
},
"engines": {
"node": ">=22",
"npm": ">=10.9.2"
}
}

View file

@ -0,0 +1,33 @@
import fs from "fs";
import path from "path";
export function validateManifest(): void {
const pkgPath = path.resolve("package.json");
if (!fs.existsSync(pkgPath)) {
throw new Error("package.json not found");
}
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
const quartz = pkg.quartz;
if (!quartz) {
console.warn(
"\x1b[33m⚠ No 'quartz' field in package.json. Plugin may not load correctly in Quartz.\x1b[0m",
);
return;
}
const warnings: string[] = [];
if (!quartz.name) warnings.push("quartz.name is missing");
if (!quartz.displayName) warnings.push("quartz.displayName is missing");
if (!quartz.category) warnings.push("quartz.category is missing");
if (!quartz.version) warnings.push("quartz.version is missing");
if (warnings.length > 0) {
console.warn("\x1b[33m⚠ Plugin manifest warnings:\x1b[0m");
for (const w of warnings) {
console.warn(` - ${w}`);
}
}
}

View file

@ -0,0 +1,32 @@
import type {
QuartzComponent,
QuartzComponentProps,
QuartzComponentConstructor,
} from "@quartz-community/types";
import { classNames } from "../util/lang";
import style from "./styles/example.scss";
// @ts-expect-error - inline script import handled by Quartz bundler
import script from "./scripts/example.inline.ts";
export interface ExampleComponentOptions {
prefix?: string;
suffix?: string;
className?: string;
}
export default ((opts?: ExampleComponentOptions) => {
const { prefix = "", suffix = "", className = "example-component" } = opts ?? {};
const Component: QuartzComponent = (props: QuartzComponentProps) => {
const frontmatter = props.fileData?.frontmatter as { title?: string } | undefined;
const title = frontmatter?.title ?? "Untitled";
const fullText = `${prefix}${title}${suffix}`;
return <div class={classNames(className)}>{fullText}</div>;
};
Component.css = style;
Component.afterDOMLoaded = script;
return Component;
}) satisfies QuartzComponentConstructor;

2
src/components/index.ts Normal file
View file

@ -0,0 +1,2 @@
export { default as ExampleComponent } from "./ExampleComponent";
export type { ExampleComponentOptions } from "./ExampleComponent";

View file

@ -0,0 +1,111 @@
// ============================================================================
// Example Inline Script for Quartz Community Plugin
// ============================================================================
// This file demonstrates patterns commonly used in Quartz plugin client-side code.
// It is bundled as a string and injected via Component.afterDOMLoaded.
//
// Key patterns demonstrated:
// 1. Listening to Quartz navigation events ('nav', 'prenav', 'render')
// 2. Fetching content index data
// 3. DOM manipulation with cleanup
// 4. State persistence (localStorage/sessionStorage)
// 5. Keyboard shortcut handling
// 6. Proper event listener cleanup
// ============================================================================
// Helper: Remove all children from an element
function _removeAllChildren(element: Element) {
while (element.firstChild) {
element.removeChild(element.firstChild);
}
}
// Helper: Simplify slug by removing trailing /index
function _simplifySlug(slug: string) {
if (slug.endsWith("/index")) {
return slug.slice(0, -6);
}
return slug;
}
// Helper: Get current page slug from URL
function getCurrentSlug() {
let slug = window.location.pathname;
if (slug.startsWith("/")) slug = slug.slice(1);
if (slug.endsWith("/")) slug = slug.slice(0, -1);
return slug || "index";
}
// Helper: Fetch content index (commonly needed for search, graph, explorer)
async function _fetchContentIndex() {
try {
const data = await fetchData;
return data;
} catch (error) {
console.error("[Plugin] Error fetching content index:", error);
return null;
}
}
// Main initialization function
function init() {
const components = document.querySelectorAll(".example-component");
if (components.length === 0) return;
// Example: Track cleanup functions for event listeners
const cleanupFns: Array<() => void> = [];
// Example: Add a keyboard shortcut (Ctrl/Cmd + Shift + E)
function keyboardHandler(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "e") {
e.preventDefault();
console.log("[ExampleComponent] Keyboard shortcut triggered!");
// Do something interesting here
}
}
document.addEventListener("keydown", keyboardHandler);
cleanupFns.push(() => document.removeEventListener("keydown", keyboardHandler));
// Example: Click handler with proper cleanup
for (const component of components) {
const clickHandler = () => {
console.log("[ExampleComponent] Clicked!");
};
component.addEventListener("click", clickHandler);
cleanupFns.push(() => component.removeEventListener("click", clickHandler));
}
// Register cleanup with Quartz's cleanup system
if (typeof window !== "undefined" && window.addCleanup) {
window.addCleanup(() => {
cleanupFns.forEach((fn) => fn());
});
}
console.log("[ExampleComponent] Initialized with", components.length, "component(s)");
}
// Listen to Quartz navigation events
// 'nav' fires after page navigation (including initial load)
// 'render' fires when DOM content changes in-place (e.g. after decryption, dynamic content)
document.addEventListener("nav", (e) => {
const slug = e.detail?.url || getCurrentSlug();
console.log("[ExampleComponent] Navigation to:", slug);
init();
});
// 'render' fires when DOM content changes in-place and components need re-initialization
document.addEventListener("render", () => {
console.log("[ExampleComponent] Render event - re-initializing");
init();
});
// 'prenav' fires before navigation - use for saving state
document.addEventListener("prenav", () => {
// Example: Save scroll position before navigation
const component = document.querySelector(".example-component");
if (component) {
sessionStorage.setItem("exampleScrollTop", component.scrollTop?.toString() || "0");
}
});

View file

@ -0,0 +1,8 @@
.example-component {
padding: 8px 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 4px;
font-weight: 600;
display: inline-block;
}

91
src/emitter.ts Normal file
View file

@ -0,0 +1,91 @@
import path from "node:path";
import fs from "node:fs/promises";
import type {
QuartzEmitterPlugin,
ProcessedContent,
BuildCtx,
FilePath,
FullSlug,
} from "@quartz-community/types";
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;
}
},
};
};

53
src/filter.ts Normal file
View file

@ -0,0 +1,53 @@
import type { QuartzFilterPlugin, ProcessedContent, BuildCtx } from "@quartz-community/types";
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;
},
};
};

9
src/i18n/index.ts Normal file
View file

@ -0,0 +1,9 @@
import enUS from "./locales/en-US";
const locales: Record<string, typeof enUS> = {
"en-US": enUS,
};
export function i18n(locale: string) {
return locales[locale] || enUS;
}

View file

@ -0,0 +1,7 @@
export default {
components: {
example: {
title: "Example",
},
},
};

28
src/index.ts Normal file
View file

@ -0,0 +1,28 @@
export { ExampleTransformer } from "./transformer";
export { ExampleFilter } from "./filter";
export { ExampleEmitter } from "./emitter";
export { default as ExampleComponent } from "./components/ExampleComponent";
export type {
ExampleTransformerOptions,
ExampleFilterOptions,
ExampleEmitterOptions,
} from "./types";
export type { ExampleComponentOptions } from "./components/ExampleComponent";
// Re-export shared types from @quartz-community/types
export type {
QuartzComponent,
QuartzComponentProps,
QuartzComponentConstructor,
StringResource,
QuartzTransformerPlugin,
QuartzFilterPlugin,
QuartzEmitterPlugin,
QuartzPageTypePlugin,
QuartzPageTypePluginInstance,
PageMatcher,
PageGenerator,
VirtualPage,
} from "@quartz-community/types";

103
src/transformer.ts Normal file
View file

@ -0,0 +1,103 @@
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, BuildCtx } from "@quartz-community/types";
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: [],
};
},
};
};

62
src/types.ts Normal file
View file

@ -0,0 +1,62 @@
export type {
BuildCtx,
ChangeEvent,
CSSResource,
JSResource,
ProcessedContent,
QuartzEmitterPlugin,
QuartzEmitterPluginInstance,
QuartzFilterPlugin,
QuartzFilterPluginInstance,
QuartzPluginData,
QuartzTransformerPlugin,
QuartzTransformerPluginInstance,
StaticResources,
PageMatcher,
PageGenerator,
VirtualPage,
QuartzPageTypePlugin,
QuartzPageTypePluginInstance,
} from "@quartz-community/types";
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;
}

1
src/util/lang.ts Normal file
View file

@ -0,0 +1 @@
export { classNames } from "@quartz-community/utils/lang";

45
test/emitter.test.ts Normal file
View file

@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import path from "node:path";
import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import { ExampleEmitter } from "../src/emitter";
import { assertFilePath, assertFullSlug, createCtx, createProcessedContent } from "./helpers";
describe("ExampleEmitter", () => {
it("writes a manifest to the output directory", async () => {
const outputDir = await fs.mkdtemp(path.join(tmpdir(), "quartz-plugin-"));
const ctx = createCtx({ argv: { output: outputDir } });
const emitter = ExampleEmitter({ manifestSlug: "manifest" });
const content = [
createProcessedContent({
slug: assertFullSlug("hello-world"),
filePath: assertFilePath("notes/hello-world.md"),
frontmatter: { title: "Hello", tags: ["docs"] },
}),
];
const result = await emitter.emit(ctx, content, {
css: [],
js: [],
additionalHead: [],
});
const outputPaths = Array.isArray(result) ? result : await collectAsync(result);
const outputPath = outputPaths[0];
if (!outputPath) {
throw new Error("Expected emitter to return an output path");
}
const manifest = JSON.parse(await fs.readFile(outputPath, "utf8"));
expect(outputPath).toContain("manifest.json");
expect(manifest.pages[0].slug).toBe("hello-world");
});
});
const collectAsync = async <T>(iterable: AsyncIterable<T>): Promise<T[]> => {
const results: T[] = [];
for await (const item of iterable) {
results.push(item);
}
return results;
};

21
test/filter.test.ts Normal file
View file

@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { ExampleFilter } from "../src/filter";
import { createCtx, createProcessedContent } from "./helpers";
describe("ExampleFilter", () => {
it("filters drafts by default", () => {
const ctx = createCtx();
const filter = ExampleFilter();
const content = createProcessedContent({ frontmatter: { title: "Draft post", draft: true } });
expect(filter.shouldPublish(ctx, content)).toBe(false);
});
it("allows drafts when configured", () => {
const ctx = createCtx();
const filter = ExampleFilter({ allowDrafts: true });
const content = createProcessedContent({ frontmatter: { title: "Draft post", draft: true } });
expect(filter.shouldPublish(ctx, content)).toBe(true);
});
});

58
test/helpers.ts Normal file
View file

@ -0,0 +1,58 @@
import type {
BuildCtx,
FilePath,
FullSlug,
QuartzConfig,
ProcessedContent,
QuartzPluginData,
} from "@quartz-community/types";
import { isFilePath, isFullSlug } from "@quartz-community/utils";
import { VFile } from "vfile";
type BuildCtxOverrides = Omit<Partial<BuildCtx>, "argv"> & {
argv?: Partial<BuildCtx["argv"]>;
};
export const createCtx = (overrides: BuildCtxOverrides = {}): BuildCtx => {
const { argv: argvOverrides, ...rest } = overrides;
const argv: BuildCtx["argv"] = {
directory: "content",
verbose: false,
output: "dist",
serve: false,
watch: false,
port: 0,
wsPort: 0,
...argvOverrides,
};
return {
buildId: "test-build",
argv,
cfg: {} as QuartzConfig,
allSlugs: [],
allFiles: [],
incremental: false,
...rest,
};
};
export const createProcessedContent = (data: Partial<QuartzPluginData> = {}): ProcessedContent => {
const vfile = new VFile("");
vfile.data = data;
return [{ type: "root", children: [] }, vfile];
};
export const assertFilePath = (value: string): FilePath => {
if (!isFilePath(value)) {
throw new Error(`Invalid FilePath: ${value}`);
}
return value;
};
export const assertFullSlug = (value: string): FullSlug => {
if (!isFullSlug(value)) {
throw new Error(`Invalid FullSlug: ${value}`);
}
return value;
};

22
test/transformer.test.ts Normal file
View file

@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkStringify from "remark-stringify";
import { ExampleTransformer } from "../src/transformer";
import { createCtx } from "./helpers";
describe("ExampleTransformer", () => {
it("highlights text wrapped in the token", async () => {
const ctx = createCtx();
const transformer = ExampleTransformer({ highlightToken: "==" });
const plugins = transformer.markdownPlugins?.(ctx) ?? [];
const file = await unified()
.use(remarkParse)
.use(plugins)
.use(remarkStringify)
.process("Hello ==Quartz==");
expect(String(file)).toContain("**Quartz**");
});
});

8
tsconfig.build.json Normal file
View file

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["node"]
},
"include": ["src", "types"],
"exclude": ["dist", "node_modules", "test"]
}

26
tsconfig.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"rootDir": ".",
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noEmit": true,
"strict": true,
"noImplicitOverride": true,
"noUncheckedIndexedAccess": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["vitest/globals", "node"],
"verbatimModuleSyntax": true,
"jsx": "react-jsx",
"jsxImportSource": "preact"
},
"include": ["src", "test", "types", "tsup.config.ts", "vitest.config.ts"],
"exclude": ["dist", "node_modules"]
}

119
tsup.config.ts Normal file
View file

@ -0,0 +1,119 @@
import { defineConfig } from "tsup";
import type { Plugin } from "esbuild";
import path from "path";
import { validateManifest } from "./src/build/validate-manifest";
validateManifest();
/**
* Esbuild plugin that bundles `.inline.ts` files into browser-ready JavaScript strings.
*
* Problem: Inline scripts are embedded as raw text into `<script>` tags in the browser.
* The previous text-loader read `.inline.ts` files verbatim, meaning TypeScript syntax
* (type annotations, `as` casts, non-null assertions, interfaces) survived into the
* browser and caused parse errors.
*
* Solution: Use `esbuild.build()` to transpile TypeScript and bundle local/npm imports
* into a single self-contained script. The result is returned as a text string that can
* be safely injected into a `<script>` tag.
*
* This mirrors Quartz v5's own `inline-script-loader` in `quartz/cli/handlers.js`.
*/
const inlineScriptPlugin: Plugin = {
name: "inline-script-loader",
setup(parentBuild) {
const absWorkingDir = parentBuild.initialOptions.absWorkingDir ?? process.cwd();
// SCSS files are compiled to CSS via sass, matching Quartz v5 core behavior
parentBuild.onLoad({ filter: /\.scss$/ }, async (args) => {
const sass = await import("sass");
const result = sass.compile(args.path);
return { contents: result.css, loader: "text" };
});
// Inline TypeScript files are transpiled + bundled for the browser
parentBuild.onLoad({ filter: /\.inline\.ts$/ }, async (args) => {
const esbuild = await import("esbuild");
const fs = await import("fs");
let text = await fs.promises.readFile(args.path, "utf8");
// Strip export statements that were added for the module system —
// inline scripts run in a <script> tag, not as ES modules
text = text.replace(/^export default /gm, "");
text = text.replace(/^export /gm, "");
const resolveDir = path.dirname(args.path);
const sourcefile = path.relative(absWorkingDir, args.path);
const result = await esbuild.build({
stdin: {
contents: text,
loader: "ts",
resolveDir,
sourcefile,
},
write: false,
bundle: true,
minify: true,
platform: "browser",
format: "esm",
target: "es2020",
sourcemap: false,
// Preserve dynamic CDN imports (e.g. graph plugin loading d3/pixi from CDN)
external: ["http://*", "https://*"],
});
const js = result.outputFiles?.[0]?.text;
if (!js) throw new Error(`inline-script-loader: no JS output for ${args.path}`);
return {
contents: js,
loader: "text",
};
});
},
};
/**
* Singleton externals: packages that MUST be the same instance at runtime
* across all plugins and the Quartz host. Everything else gets bundled.
*/
const SINGLETON_EXTERNALS = [
"preact",
"preact/hooks",
"preact/jsx-runtime",
"preact/compat",
"@jackyzha0/quartz",
"@jackyzha0/quartz/*",
"vfile",
"vfile/*",
"unified",
];
export default defineConfig({
entry: {
index: "src/index.ts",
types: "src/types.ts",
"components/index": "src/components/index.ts",
},
format: ["esm"],
dts: true,
tsconfig: "tsconfig.build.json",
sourcemap: true,
clean: true,
treeshake: true,
target: "es2022",
splitting: false,
outDir: "dist",
platform: "node",
noExternal: [/.*/],
external: SINGLETON_EXTERNALS,
banner: {
js: 'import { createRequire } from "module"; const require = createRequire(import.meta.url);',
},
esbuildOptions(options) {
options.jsx = "automatic";
options.jsxImportSource = "preact";
},
esbuildPlugins: [inlineScriptPlugin],
});

11
types/globals.d.ts vendored Normal file
View file

@ -0,0 +1,11 @@
/// <reference path="../node_modules/@quartz-community/types/globals.d.ts" />
declare module "*.scss" {
const content: string;
export default content;
}
declare module "*.inline.ts" {
const content: string;
export default content;
}

9
vitest.config.ts Normal file
View file

@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["test/**/*.test.ts"],
reporters: ["default"],
},
});