mirror of
https://github.com/quartz-community/fonts.git
synced 2026-07-22 03:00:28 +00:00
Initial commit
This commit is contained in:
commit
a5aa9d744d
44 changed files with 11262 additions and 0 deletions
41
.eslintrc.json
Normal file
41
.eslintrc.json
Normal 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
96
.github/workflows/ci.yml
vendored
Normal 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
41
.gitignore
vendored
Normal 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
1
.prettierignore
Normal file
|
|
@ -0,0 +1 @@
|
|||
dist
|
||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "all",
|
||||
"arrowParens": "always"
|
||||
}
|
||||
94
AGENTS.md
Normal file
94
AGENTS.md
Normal 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
73
ARCHITECTURE.md
Normal 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
12
CHANGELOG.md
Normal 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
132
EXAMPLES.md
Normal 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
21
LICENSE
Normal 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
307
README.md
Normal 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
10
dist/components/index.d.ts
vendored
Normal 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
51
dist/components/index.js
vendored
Normal 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
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
21
dist/index.d.ts
vendored
Normal 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
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
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
42
dist/types.d.ts
vendored
Normal 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
5
dist/types.js
vendored
Normal 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
1
dist/types.js.map
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"sources":[],"names":[],"mappings":"","file":"types.js"}
|
||||
5539
package-lock.json
generated
Normal file
5539
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
128
package.json
Normal file
128
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
33
src/build/validate-manifest.ts
Normal file
33
src/build/validate-manifest.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/components/ExampleComponent.tsx
Normal file
32
src/components/ExampleComponent.tsx
Normal 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
2
src/components/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as ExampleComponent } from "./ExampleComponent";
|
||||
export type { ExampleComponentOptions } from "./ExampleComponent";
|
||||
111
src/components/scripts/example.inline.ts
Normal file
111
src/components/scripts/example.inline.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
8
src/components/styles/example.scss
Normal file
8
src/components/styles/example.scss
Normal 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
91
src/emitter.ts
Normal 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
53
src/filter.ts
Normal 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
9
src/i18n/index.ts
Normal 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;
|
||||
}
|
||||
7
src/i18n/locales/en-US.ts
Normal file
7
src/i18n/locales/en-US.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export default {
|
||||
components: {
|
||||
example: {
|
||||
title: "Example",
|
||||
},
|
||||
},
|
||||
};
|
||||
28
src/index.ts
Normal file
28
src/index.ts
Normal 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
103
src/transformer.ts
Normal 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
62
src/types.ts
Normal 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
1
src/util/lang.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { classNames } from "@quartz-community/utils/lang";
|
||||
45
test/emitter.test.ts
Normal file
45
test/emitter.test.ts
Normal 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
21
test/filter.test.ts
Normal 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
58
test/helpers.ts
Normal 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
22
test/transformer.test.ts
Normal 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
8
tsconfig.build.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src", "types"],
|
||||
"exclude": ["dist", "node_modules", "test"]
|
||||
}
|
||||
26
tsconfig.json
Normal file
26
tsconfig.json
Normal 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
119
tsup.config.ts
Normal 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
11
types/globals.d.ts
vendored
Normal 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
9
vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["test/**/*.test.ts"],
|
||||
reporters: ["default"],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue