Initial commit

This commit is contained in:
Emile Bangma 2026-02-17 14:12:16 +01:00 committed by GitHub
commit 1ca2cea71e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 6626 additions and 0 deletions

26
.eslintrc.json Normal file
View file

@ -0,0 +1,26 @@
{
"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": "^_"
}
]
}
}

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

@ -0,0 +1,55 @@
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
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 }}

40
.gitignore vendored Normal file
View file

@ -0,0 +1,40 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Build output
dist/
build/
*.tsbuildinfo
# 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

7
.prettierrc Normal file
View file

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

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.

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.

247
README.md Normal file
View file

@ -0,0 +1,247 @@
# 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
- ✅ 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
```
## 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.ts`:
```ts
import * as ExternalPlugin from "./.quartz/plugins";
export default {
configuration: {
pageTitle: "My Garden",
},
plugins: {
transformers: [ExternalPlugin.ExampleTransformer({ highlightToken: "==" })],
filters: [ExternalPlugin.ExampleFilter({ allowDrafts: false })],
emitters: [ExternalPlugin.ExampleEmitter({ manifestSlug: "plugin-manifest" })],
},
externalPlugins: ["github:quartz-community/plugin-template"],
};
```
## 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;
```
### 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. **Fetch `/static/contentIndex.json`** - Access page metadata for search, graph, etc.
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. **Fetch data directly** from `/static/contentIndex.json` instead of using `fetchData`
5. **Handle both data formats**: `data.content || data` for contentIndex compatibility
6. **Test with both local and production builds**
## License
MIT

5345
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

97
package.json Normal file
View file

@ -0,0 +1,97 @@
{
"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",
"prepare": "npm run build",
"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": {
"@jackyzha0/quartz": "^4.5.2",
"preact": "^10.0.0"
},
"peerDependenciesMeta": {
"@jackyzha0/quartz": {
"optional": true
},
"preact": {
"optional": false
}
},
"dependencies": {
"@quartz-community/types": "github:quartz-community/types",
"mdast-util-find-and-replace": "^3.0.1",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.3"
},
"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",
"preact": "^10.28.2",
"prettier": "^3.6.2",
"tsup": "^8.5.0",
"typescript": "^5.9.3",
"vitest": "^2.1.9"
},
"engines": {
"node": ">=22",
"npm": ">=10.9.2"
}
}

View file

@ -0,0 +1,33 @@
import type {
QuartzComponent,
QuartzComponentProps,
QuartzComponentConstructor,
} from "@quartz-community/types";
import { classNames } from "../util/lang";
import { i18n } from "../i18n";
import style from "./styles/example.scss";
// @ts-ignore
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";

4
src/components/scripts.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module "*.inline.ts" {
const content: string;
export default content;
}

View file

@ -0,0 +1,107 @@
// @ts-nocheck
// ============================================================================
// 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')
// 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) {
while (element.firstChild) {
element.removeChild(element.firstChild);
}
}
// Helper: Simplify slug by removing trailing /index
function simplifySlug(slug) {
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 response = await fetch("/static/contentIndex.json");
const data = await response.json();
// Handle both formats: { "slug": {...} } or { "content": { "slug": {...} } }
return data.content || 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 = [];
// Example: Add a keyboard shortcut (Ctrl/Cmd + Shift + E)
function keyboardHandler(e) {
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)
document.addEventListener("nav", (e) => {
const slug = e.detail?.url || getCurrentSlug();
console.log("[ExampleComponent] Navigation to:", slug);
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");
}
});

4
src/components/styles.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module "*.scss" {
const content: string;
export default content;
}

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;
}

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

@ -0,0 +1,5 @@
export function classNames(
...classes: (string | undefined | null | false)[]
): string {
return classes.filter(Boolean).join(" ");
}

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 { 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: "hello-world",
filePath: "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: { 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: { draft: true } });
expect(filter.shouldPublish(ctx, content)).toBe(true);
});
});

41
test/helpers.ts Normal file
View file

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

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**");
});
});

26
tsconfig.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM"],
"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"]
}

96
tsup.config.ts Normal file
View file

@ -0,0 +1,96 @@
import { defineConfig } from "tsup";
import type { Plugin } from "esbuild";
import path from "path";
/**
* 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 loaded as raw text (CSS injected via style tags)
parentBuild.onLoad({ filter: /\.scss$/ }, async (args) => {
const fs = await import("fs");
const text = await fs.promises.readFile(args.path, "utf8");
return {
contents: text,
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",
};
});
},
};
export default defineConfig({
entry: {
index: "src/index.ts",
types: "src/types.ts",
"components/index": "src/components/index.ts",
},
format: ["esm"],
dts: true,
sourcemap: true,
clean: true,
treeshake: true,
target: "es2022",
splitting: false,
outDir: "dist",
esbuildOptions(options) {
options.jsx = "automatic";
options.jsxImportSource = "preact";
},
esbuildPlugins: [inlineScriptPlugin],
});

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"],
},
});