feat: initial UnlistedPages transformer

Zero-config rehype plugin that copies file.data.frontmatter.unlisted onto
file.data.unlisted when the value is a boolean. Enables the unlisted
convention respected by content-index, search, backlinks, recent-notes,
folder-page, and tag-page to work for any page, not only encrypted ones.

Before this plugin, file.data.unlisted was only set by
@quartz-community/encrypted-pages, inside the encryption branch of its
transformer. Pages that had unlisted: true in frontmatter but no password
were silently listed everywhere -- the frontmatter field was dead weight.
This plugin fixes that with a four-line rehype pass.

Composes cleanly with encrypted-pages. If both are installed, the two
plugins write the same value to file.data.unlisted; if only one is
installed, the other's behavior is unaffected.

Strips the template scaffolding (filter/emitter/component/i18n/util/build)
since this is a single zero-config transformer. Manifest declares
category: "transformer", defaultOrder: 45 (runs before encrypted-pages at
900, independent of crawl-links at 60), defaultEnabled: true, no options.

10 unit tests covering boolean true, boolean false, missing frontmatter,
missing unlisted field, non-boolean values (string, number, null),
preservation of other file.data fields, transformer name, and zero-arg
instantiation.
This commit is contained in:
saberzero1 2026-04-11 14:37:53 +02:00
parent 876b50f69e
commit 33351a77aa
No known key found for this signature in database
32 changed files with 200 additions and 6758 deletions

View file

@ -1,90 +0,0 @@
# 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.
- `.github/`: CI/CD workflows (unless customizing publishing).
- `tsup.config.ts`: Build configuration.
## 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**: Run `npm run build` to verify the bundle.
## 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`.
## 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.
- [ ] `package.json` manifest is complete and accurate.
- [ ] `README.md` documents all options.
## Build System Quirks
- **.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.

View file

@ -1,73 +0,0 @@
# 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.

View file

@ -9,4 +9,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Initial Quartz community plugin template.
- Initial release.
- `UnlistedPages` transformer: zero-config rehype plugin that copies `file.data.frontmatter.unlisted` to `file.data.unlisted` when it is a boolean. Enables the `file.data.unlisted` convention respected by `content-index`, `search`, `backlinks`, `recent-notes`, `folder-page`, and `tag-page` to work for any page, not only encrypted ones.

View file

@ -1,132 +0,0 @@
# 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;
}
```

330
README.md
View file

@ -1,290 +1,80 @@
# Quartz Community Plugin Template
# @quartz-community/unlisted-pages
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.
Zero-config transformer that bridges `frontmatter.unlisted` to `file.data.unlisted` so any page can opt out of discovery surfaces while remaining accessible by direct URL.
## Highlights
## What it does
- ✅ 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;
```
### 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`:
Quartz v5 plugins that respect the `file.data.unlisted` convention — `content-index`, `search`, `backlinks`, `recent-notes`, `folder-page`, and `tag-page` — will hide a page from their output when `file.data.unlisted === true`. But nothing in the core pipeline copies `frontmatter.unlisted` onto `file.data.unlisted`, so a user writing:
```yaml
plugins:
- source: github:your-username/my-component-plugin
enabled: true
options:
myOption: true
---
title: My Draft
unlisted: 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.
gets no effect. This plugin fixes that. It is a trivially small rehype plugin that does one thing: if `frontmatter.unlisted` is a boolean, copy it to `file.data.unlisted`.
### Client-Side Scripts
## Installation
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";
}
```bash
npx quartz plugin add github:quartz-community/unlisted-pages
```
### State Persistence
## Usage
Use `localStorage` for persistent state (survives browser close) and `sessionStorage` for
temporary state (like scroll positions):
Add an `unlisted` field to any page's frontmatter:
```js
localStorage.setItem("myPlugin-state", JSON.stringify(state));
sessionStorage.setItem("myPlugin-scrollTop", element.scrollTop.toString());
```yaml
---
title: My Draft
unlisted: true
---
```
## Migration Guide (from Quartz v4)
When registered, this plugin marks the page as unlisted. Every Quartz v5 plugin that respects the convention will then hide it:
When migrating a v4 component to a standalone plugin:
- **Absent from** `contentIndex.json`, `sitemap.xml`, the RSS feed, backlinks, recent notes, folder listings, tag listings, graph, explorer, and search.
- **Still emitted** as HTML, so the page remains accessible by direct URL.
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**
## Configuration
Zero options. Just enable it.
```yaml title="quartz.config.yaml"
- source: github:quartz-community/unlisted-pages
enabled: true
```
## Interaction with `@quartz-community/encrypted-pages`
The `encrypted-pages` plugin also sets `file.data.unlisted` when `unlistWhenEncrypted: true` or when per-page frontmatter specifies `unlisted: true`. The two plugins compose cleanly:
- If you install only `unlisted-pages`: any page with `unlisted: true` in frontmatter is hidden from listing surfaces. Encryption is independent.
- If you install only `encrypted-pages`: `unlisted: true` only works on pages that are ALSO encrypted (have a password). Non-encrypted pages with `unlisted: true` are silently ignored.
- If you install both: `unlisted: true` works for every page, encrypted or not. This is the recommended setup for sites that use encrypted pages.
## How `unlisted` works across consumer plugins
| Plugin | Behavior when `file.data.unlisted === true` |
| --------------- | -------------------------------------------------------------------------- |
| `content-index` | Page absent from `contentIndex.json`, `sitemap.xml`, and the RSS feed. |
| `search` | Page absent from search results (derived from `contentIndex.json`). |
| `graph` | Page absent from graph nodes and edges (derived from `contentIndex.json`). |
| `explorer` | Page absent from the sidebar file tree (derived from `contentIndex.json`). |
| `backlinks` | Page never appears as a backlink source on other pages. |
| `recent-notes` | Page absent from the recent notes list. |
| `folder-page` | Page absent from folder listings and folder discovery. |
| `tag-page` | Page absent from tag listings and tag discovery. |
In every case, the page's HTML is still emitted and accessible by direct URL.
## API
- Category: Transformer
- Function name: `UnlistedPages()`
- Source: [`quartz-community/unlisted-pages`](https://github.com/quartz-community/unlisted-pages)
- Install: `npx quartz plugin add github:quartz-community/unlisted-pages`
## License

View file

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

View file

@ -1,31 +0,0 @@
import { createRequire } from 'module';
import { jsx } from 'preact/jsx-runtime';
createRequire(import.meta.url);
// src/util/lang.ts
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 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__ */ jsx("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

View file

@ -1 +0,0 @@
{"version":3,"sources":["../../src/util/lang.ts","../../src/components/styles/example.scss","../../src/components/scripts/example.inline.ts","../../src/components/ExampleComponent.tsx"],"names":[],"mappings":";;;;;;AAAO,SAAS,cAAc,OAAA,EAAwD;AACpF,EAAA,OAAO,OAAA,CAAQ,MAAA,CAAO,OAAO,CAAA,CAAE,KAAK,GAAG,CAAA;AACzC;;;ACFA,IAAA,eAAA,GAAA,wMAAA;;;ACAA,IAAA,sBAAA,GAAA,mqCAAA;ACgBA,IAAO,wBAAA,IAAS,CAAC,IAAA,KAAmC;AAClD,EAAA,MAAM,EAAE,SAAS,EAAA,EAAI,MAAA,GAAS,IAAI,SAAA,GAAY,mBAAA,EAAoB,GAAI,IAAA,IAAQ,EAAC;AAE/E,EAAA,MAAM,SAAA,GAA6B,CAAC,KAAA,KAAgC;AAClE,IAAA,MAAM,WAAA,GAAc,MAAM,QAAA,EAAU,WAAA;AACpC,IAAA,MAAM,KAAA,GAAQ,aAAa,KAAA,IAAS,UAAA;AACpC,IAAA,MAAM,WAAW,CAAA,EAAG,MAAM,CAAA,EAAG,KAAK,GAAG,MAAM,CAAA,CAAA;AAE3C,IAAA,2BAAQ,KAAA,EAAA,EAAI,KAAA,EAAO,UAAA,CAAW,SAAS,GAAI,QAAA,EAAA,QAAA,EAAS,CAAA;AAAA,EACtD,CAAA;AAEA,EAAA,SAAA,CAAU,GAAA,GAAM,eAAA;AAChB,EAAA,SAAA,CAAU,cAAA,GAAiB,sBAAA;AAE3B,EAAA,OAAO,SAAA;AACT,CAAA","file":"index.js","sourcesContent":["export function classNames(...classes: (string | undefined | null | false)[]): string {\n return classes.filter(Boolean).join(\" \");\n}\n",".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}","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","import type {\n QuartzComponent,\n QuartzComponentProps,\n QuartzComponentConstructor,\n} from \"@quartz-community/types\";\nimport { classNames } from \"../util/lang\";\nimport style from \"./styles/example.scss\";\n// @ts-expect-error - inline script import handled by Quartz bundler\nimport script from \"./scripts/example.inline.ts\";\n\nexport interface ExampleComponentOptions {\n prefix?: string;\n suffix?: string;\n className?: string;\n}\n\nexport default ((opts?: ExampleComponentOptions) => {\n const { prefix = \"\", suffix = \"\", className = \"example-component\" } = opts ?? {};\n\n const Component: QuartzComponent = (props: QuartzComponentProps) => {\n const frontmatter = props.fileData?.frontmatter as { title?: string } | undefined;\n const title = frontmatter?.title ?? \"Untitled\";\n const fullText = `${prefix}${title}${suffix}`;\n\n return <div class={classNames(className)}>{fullText}</div>;\n };\n\n Component.css = style;\n Component.afterDOMLoaded = script;\n\n return Component;\n}) satisfies QuartzComponentConstructor;\n"]}

23
dist/index.d.ts vendored
View file

@ -1,21 +1,6 @@
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';
import { QuartzTransformerPlugin } from '@quartz-community/types';
export { QuartzPluginData, QuartzTransformerPlugin, QuartzTransformerPluginInstance } from '@quartz-community/types';
/**
* Example transformer showing remark/rehype usage and resource injection.
*/
declare const ExampleTransformer: QuartzTransformerPlugin<Partial<ExampleTransformerOptions>>;
declare const UnlistedPages: QuartzTransformerPlugin<undefined>;
/**
* 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 };
export { UnlistedPages };

3759
dist/index.js vendored

File diff suppressed because one or more lines are too long

2
dist/index.js.map vendored

File diff suppressed because one or more lines are too long

43
dist/types.d.ts vendored
View file

@ -1,42 +1 @@
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 };
export { BuildCtx, ProcessedContent, QuartzPluginData, QuartzTransformerPlugin, QuartzTransformerPluginInstance } from '@quartz-community/types';

2
dist/types.js vendored
View file

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

1632
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,23 +1,24 @@
{
"name": "@quartz-community/plugin-template",
"name": "@quartz-community/unlisted-pages",
"version": "0.1.0",
"description": "Template repository for Quartz community plugins.",
"description": "Bridges `frontmatter.unlisted` to `file.data.unlisted` so any page can opt out of discovery surfaces (contentIndex, RSS, sitemap, graph, explorer, search, backlinks, recent-notes, folder-page, tag-page) while remaining accessible by direct URL.",
"type": "module",
"license": "MIT",
"author": "Quartz Community",
"homepage": "https://quartz.jzhao.xyz",
"repository": {
"type": "git",
"url": "https://github.com/quartz-community/plugin-template"
"url": "https://github.com/quartz-community/unlisted-pages"
},
"keywords": [
"quartz",
"quartz-plugin",
"plugin-template",
"remark",
"unlisted-pages",
"unlisted",
"hidden",
"rehype",
"mdast",
"hast"
"hast",
"frontmatter"
],
"files": [
"dist",
@ -34,10 +35,6 @@
"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",
@ -54,16 +51,12 @@
},
"peerDependencies": {
"@jackyzha0/quartz": "^4.5.2",
"preact": "^10.0.0",
"vfile": "^6.0.3"
},
"peerDependenciesMeta": {
"@jackyzha0/quartz": {
"optional": true
},
"preact": {
"optional": false
},
"vfile": {
"optional": true
}
@ -74,56 +67,29 @@
},
"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",
"name": "unlisted-pages",
"displayName": "Unlisted Pages",
"category": "transformer",
"version": "1.0.0",
"quartzVersion": ">=5.0.0",
"dependencies": [],
"defaultOrder": 50,
"defaultOrder": 45,
"defaultEnabled": true,
"defaultOptions": {},
"optionSchema": {
"highlightToken": {
"type": "enum",
"values": [
"==",
"**",
"~~"
]
}
},
"components": {
"ExampleComponent": {
"displayName": "Example Component",
"defaultPosition": "right",
"defaultPriority": 50
}
}
"optionSchema": {}
},
"engines": {
"node": ">=22",

View file

@ -1,32 +0,0 @@
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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -1,28 +1,7 @@
export { ExampleTransformer } from "./transformer";
export { ExampleFilter } from "./filter";
export { ExampleEmitter } from "./emitter";
export { default as ExampleComponent } from "./components/ExampleComponent";
export { UnlistedPages } from "./transformer";
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,
QuartzTransformerPluginInstance,
QuartzPluginData,
} from "@quartz-community/types";

View file

@ -1,103 +1,23 @@
import type { PluggableList, Plugin } from "unified";
import type { Root as MdastRoot } from "mdast";
import type { Root as HastRoot, Element } from "hast";
import type { Root as HastRoot } 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";
import type { QuartzTransformerPlugin } from "@quartz-community/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 rehypeUnlisted = (): Plugin<[], HastRoot> => {
return () => (_tree: HastRoot, file: VFile) => {
const frontmatter = file.data?.frontmatter as Record<string, unknown> | undefined;
const unlisted = frontmatter?.unlisted;
if (typeof unlisted === "boolean") {
(file.data as Record<string, unknown>).unlisted = unlisted;
}
};
};
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 };
export const UnlistedPages: QuartzTransformerPlugin<undefined> = () => {
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;
},
name: "UnlistedPages",
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: [],
};
return [rehypeUnlisted()];
},
};
};

View file

@ -1,62 +1,7 @@
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;
}

View file

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

View file

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

View file

@ -1,21 +0,0 @@
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);
});
});

View file

@ -1,12 +1,9 @@
import type {
BuildCtx,
FilePath,
FullSlug,
QuartzConfig,
ProcessedContent,
QuartzConfig,
QuartzPluginData,
} from "@quartz-community/types";
import { isFilePath, isFullSlug } from "@quartz-community/utils";
import { VFile } from "vfile";
type BuildCtxOverrides = Omit<Partial<BuildCtx>, "argv"> & {
@ -42,17 +39,3 @@ export const createProcessedContent = (data: Partial<QuartzPluginData> = {}): Pr
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;
};

View file

@ -1,22 +1,98 @@
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 { VFile } from "vfile";
import type { Root as HastRoot } from "hast";
import { UnlistedPages } from "../src/transformer";
import { createCtx } from "./helpers";
describe("ExampleTransformer", () => {
it("highlights text wrapped in the token", async () => {
async function runTransformer(
frontmatter: Record<string, unknown> | undefined,
): Promise<Record<string, unknown>> {
const tree: HastRoot = { type: "root", children: [] };
const vfile = new VFile("");
(vfile as unknown as { data: Record<string, unknown> }).data =
frontmatter !== undefined ? { frontmatter } : {};
const ctx = createCtx();
const transformer = UnlistedPages();
const plugins = transformer.htmlPlugins?.(ctx) ?? [];
for (const pluginEntry of plugins) {
const pluginFn = Array.isArray(pluginEntry) ? pluginEntry[0] : pluginEntry;
const attacher = pluginFn as () => (tree: HastRoot, file: VFile) => void;
const transform = attacher();
await transform(tree, vfile);
}
return vfile.data as Record<string, unknown>;
}
describe("UnlistedPages transformer", () => {
it("copies frontmatter.unlisted = true onto file.data.unlisted", async () => {
const data = await runTransformer({ title: "Secret", unlisted: true });
expect(data.unlisted).toBe(true);
});
it("copies frontmatter.unlisted = false onto file.data.unlisted", async () => {
const data = await runTransformer({ title: "Public", unlisted: false });
expect(data.unlisted).toBe(false);
});
it("leaves file.data.unlisted undefined when frontmatter has no unlisted field", async () => {
const data = await runTransformer({ title: "Plain page" });
expect(data.unlisted).toBeUndefined();
});
it("leaves file.data.unlisted undefined when there is no frontmatter at all", async () => {
const data = await runTransformer(undefined);
expect(data.unlisted).toBeUndefined();
});
it("ignores non-boolean unlisted values (string)", async () => {
const data = await runTransformer({ title: "Bogus", unlisted: "true" });
expect(data.unlisted).toBeUndefined();
});
it("ignores non-boolean unlisted values (number)", async () => {
const data = await runTransformer({ title: "Bogus", unlisted: 1 });
expect(data.unlisted).toBeUndefined();
});
it("ignores non-boolean unlisted values (null)", async () => {
const data = await runTransformer({ title: "Bogus", unlisted: null });
expect(data.unlisted).toBeUndefined();
});
it("does not touch other file.data fields", async () => {
const tree: HastRoot = { type: "root", children: [] };
const vfile = new VFile("");
(vfile as unknown as { data: Record<string, unknown> }).data = {
slug: "notes/a",
text: "hello",
frontmatter: { title: "A", unlisted: true },
};
const ctx = createCtx();
const transformer = ExampleTransformer({ highlightToken: "==" });
const plugins = transformer.markdownPlugins?.(ctx) ?? [];
const transformer = UnlistedPages();
const plugins = transformer.htmlPlugins?.(ctx) ?? [];
for (const pluginEntry of plugins) {
const pluginFn = Array.isArray(pluginEntry) ? pluginEntry[0] : pluginEntry;
const attacher = pluginFn as () => (tree: HastRoot, file: VFile) => void;
const transform = attacher();
await transform(tree, vfile);
}
const file = await unified()
.use(remarkParse)
.use(plugins)
.use(remarkStringify)
.process("Hello ==Quartz==");
const data = vfile.data as Record<string, unknown>;
expect(data.unlisted).toBe(true);
expect(data.slug).toBe("notes/a");
expect(data.text).toBe("hello");
});
expect(String(file)).toContain("**Quartz**");
it("exposes a transformer with name 'UnlistedPages'", () => {
const transformer = UnlistedPages();
expect(transformer.name).toBe("UnlistedPages");
});
it("is callable with no arguments (zero-config)", () => {
expect(() => UnlistedPages()).not.toThrow();
});
});

View file

@ -1,84 +1,12 @@
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",
};
});
},
};
export default defineConfig({
entry: {
index: "src/index.ts",
types: "src/types.ts",
"components/index": "src/components/index.ts",
},
format: ["esm"],
dts: true,
@ -90,12 +18,4 @@ export default defineConfig({
splitting: false,
outDir: "dist",
platform: "node",
banner: {
js: 'import { createRequire } from "module"; const require = createRequire(import.meta.url);',
},
esbuildOptions(options) {
options.jsx = "automatic";
options.jsxImportSource = "preact";
},
esbuildPlugins: [inlineScriptPlugin],
});