feat: migrate to shared utility library

- Remove plugin template code
- Add path utilities (simplifySlug, joinSegments, etc.)
- Add DOM utilities (removeAllChildren, registerEscapeHandler)
- Add lang utilities (classNames)
- Update package.json for @quartz-community/utils
- Add tests for path utilities
This commit is contained in:
saberzero1 2026-02-09 12:38:53 +01:00
parent 09c5a21ea0
commit 61970ce89f
No known key found for this signature in database
27 changed files with 522 additions and 2100 deletions

206
README.md
View file

@ -1,166 +1,82 @@
# Quartz Community Plugin Template
# @quartz-community/utils
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.
Shared utility functions for Quartz community plugins.
## Highlights
- ✅ Quartz-compatible transformer/filter/emitter examples
- ✅ TypeScript-first with exported types for consumers
- ✅ `tsup` bundling + declaration output
- ✅ Vitest testing setup with example tests
- ✅ Linting/formatting with ESLint + Prettier
- ✅ CI workflow for checks and npm publishing
- ✅ Demonstrates CSS/JS resource injection and remark/rehype usage
## Getting started
## Installation
```bash
npm install
npm run build
npm install @quartz-community/utils
```
## Usage in Quartz
Or using GitHub:
Install your plugin into a Quartz site and register it in `quartz.config.ts`:
```bash
npm install github:quartz-community/utils
```
## Usage
```ts
import {
ExampleTransformer,
ExampleFilter,
ExampleEmitter,
} from "@quartz-community/plugin-template";
simplifySlug,
getFullSlug,
joinSegments,
removeAllChildren,
classNames,
} from "@quartz-community/utils";
export default {
configuration: {
pageTitle: "My Garden",
},
plugins: {
transformers: [ExampleTransformer({ highlightToken: "==" })],
filters: [ExampleFilter({ allowDrafts: false })],
emitters: [ExampleEmitter({ manifestSlug: "plugin-manifest" })],
},
};
// Path utilities
const slug = simplifySlug("folder/index"); // "folder/"
const currentSlug = getFullSlug(window); // e.g., "blog/my-post"
const path = joinSegments("a", "b", "c"); // "a/b/c"
// DOM utilities
removeAllChildren(document.getElementById("container")!);
// Language utilities
const classes = classNames("btn", isActive && "active", null); // "btn active"
```
## Plugin factory pattern (Astro-style)
## Modules
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.
### Path (`@quartz-community/utils/path`)
Path manipulation utilities for Quartz slugs:
- `simplifySlug(slug)` - Remove `/index` suffix from slugs
- `getFullSlug(window)` - Get current page slug from document body dataset
- `getFullSlugFromUrl()` - Get current page slug from URL pathname
- `joinSegments(...segments)` - Join path segments with proper slash handling
- `resolvePath(path)` - Ensure path starts with `/`
- `endsWith(str, suffix)` - Check if path ends with suffix
- `trimSuffix(str, suffix)` - Remove suffix from path
- `stripSlashes(str, onlyPrefix?)` - Remove leading/trailing slashes
- `getFileExtension(path)` - Get file extension
- `isFolderPath(path)` - Check if path represents a folder
- `getAllSegmentPrefixes(path)` - Get all path prefixes
### DOM (`@quartz-community/utils/dom`)
DOM manipulation utilities:
- `removeAllChildren(element)` - Remove all child nodes from an element
- `registerEscapeHandler(container, callback)` - Register Escape key and click-outside handlers
- `normalizeRelativeURLs(document, baseUrl)` - Convert relative URLs to absolute
### Lang (`@quartz-community/utils/lang`)
Language/general utilities:
- `classNames(...classes)` - Combine CSS class names, filtering falsy values
## Types
The package exports TypeScript types for type-safe slug handling:
```ts
import type { QuartzTransformerPlugin } from "@jackyzha0/quartz/plugins/types";
export const MyTransformer: QuartzTransformerPlugin<{ enabled: boolean }> = (opts) => {
return {
name: "MyTransformer",
markdownPlugins() {
return [];
},
};
};
import type { FullSlug, SimpleSlug, RelativeURL } from "@quartz-community/utils";
```
## 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.
## License
MIT

1257
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,23 +1,21 @@
{
"name": "@quartz-community/plugin-template",
"name": "@quartz-community/utils",
"version": "0.1.0",
"description": "Template repository for Quartz community plugins.",
"description": "Shared utility functions 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"
"url": "https://github.com/quartz-community/utils"
},
"keywords": [
"quartz",
"quartz-plugin",
"plugin-template",
"remark",
"rehype",
"mdast",
"hast"
"utilities",
"path",
"dom"
],
"files": [
"dist",
@ -30,13 +28,17 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./types": {
"types": "./dist/types.d.ts",
"import": "./dist/types.js"
"./path": {
"types": "./dist/path.d.ts",
"import": "./dist/path.js"
},
"./components": {
"types": "./dist/components/index.d.ts",
"import": "./dist/components/index.js"
"./dom": {
"types": "./dist/dom.d.ts",
"import": "./dist/dom.js"
},
"./lang": {
"types": "./dist/lang.d.ts",
"import": "./dist/lang.js"
},
"./package.json": "./package.json"
},
@ -53,38 +55,12 @@
"typecheck": "tsc --noEmit",
"check": "npm run typecheck && npm run lint && npm run format && npm run test"
},
"peerDependencies": {
"@jackyzha0/quartz": "^4.5.2",
"preact": "^10.0.0"
},
"peerDependenciesMeta": {
"@jackyzha0/quartz": {
"optional": true
},
"preact": {
"optional": false
}
},
"dependencies": {
"@quartz-community/types": "github:quartz-community/types",
"mdast-util-find-and-replace": "^3.0.1",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.3"
},
"devDependencies": {
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.4",
"@types/node": "^24.10.0",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"preact": "^10.28.2",
"prettier": "^3.6.2",
"tsup": "^8.5.0",
"typescript": "^5.9.3",

View file

@ -1,32 +0,0 @@
import type {
QuartzComponent,
QuartzComponentProps,
QuartzComponentConstructor,
} from "@quartz-community/types";
import { classNames } from "../util/lang";
import { i18n } from "../i18n";
import style from "./styles/example.scss";
// @ts-ignore
import script from "./scripts/example.inline.ts";
export interface ExampleComponentOptions {
prefix?: string;
suffix?: string;
className?: string;
}
export default ((opts?: ExampleComponentOptions) => {
const { prefix = "", suffix = "", className = "example-component" } = opts ?? {};
const Component: QuartzComponent = (props: QuartzComponentProps) => {
const title = props.fileData?.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,4 +0,0 @@
declare module "*.inline.ts" {
const content: string;
export default content;
}

View file

@ -1,3 +0,0 @@
document.addEventListener('nav', () => {
console.log('ExampleComponent: page navigation occurred');
});

View file

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

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

59
src/dom.ts Normal file
View file

@ -0,0 +1,59 @@
export function removeAllChildren(el: HTMLElement): void {
while (el.firstChild) {
el.removeChild(el.firstChild);
}
}
export function registerEscapeHandler(
outsideContainer: HTMLElement | null,
onEscape: () => void,
): () => void {
if (!outsideContainer) return () => {};
const onKeydown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onEscape();
}
};
const onClick = (e: MouseEvent) => {
if (e.target instanceof Node && !outsideContainer.contains(e.target)) {
onEscape();
}
};
document.addEventListener("keydown", onKeydown);
document.addEventListener("click", onClick);
return () => {
document.removeEventListener("keydown", onKeydown);
document.removeEventListener("click", onClick);
};
}
export function normalizeRelativeURLs(html: Document, baseUrl: string): void {
const elements = html.querySelectorAll<HTMLElement>("[src], [href]");
for (const el of Array.from(elements)) {
const attr = el.hasAttribute("href") ? "href" : "src";
const val = el.getAttribute(attr);
if (!val) continue;
if (
val.startsWith("http://") ||
val.startsWith("https://") ||
val.startsWith("mailto:") ||
val.startsWith("tel:") ||
val.startsWith("#") ||
val.startsWith("/") ||
val.startsWith("data:")
) {
continue;
}
try {
const normalized = new URL(val, baseUrl).toString();
el.setAttribute(attr, normalized);
} catch {
continue;
}
}
}

View file

@ -1,88 +0,0 @@
import path from "node:path";
import fs from "node:fs/promises";
import type { QuartzEmitterPlugin } from "@jackyzha0/quartz/plugins/types";
import type { ProcessedContent } from "@jackyzha0/quartz/plugins/vfile";
import type { BuildCtx } from "@jackyzha0/quartz/util/ctx";
import type { FilePath, FullSlug } from "@jackyzha0/quartz/util/path";
import type { ExampleEmitterOptions } from "./types";
const defaultOptions: ExampleEmitterOptions = {
manifestSlug: "plugin-manifest",
includeFrontmatter: true,
metadata: {
generator: "Quartz Plugin Template",
},
};
const joinSegments = (...segments: string[]) =>
segments
.filter((segment) => segment.length > 0)
.join("/")
.replace(/\/+/g, "/") as FilePath;
const writeFile = async (
outputDir: string,
slug: FullSlug,
ext: `.${string}` | "",
content: string,
) => {
const outputPath = joinSegments(outputDir, `${slug}${ext}`) as FilePath;
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, content);
return outputPath;
};
/**
* Example emitter that writes a JSON manifest of content metadata.
*/
export const ExampleEmitter: QuartzEmitterPlugin<Partial<ExampleEmitterOptions>> = (
userOptions?: Partial<ExampleEmitterOptions>,
) => {
const options = { ...defaultOptions, ...userOptions };
const emitManifest = async (ctx: BuildCtx, content: ProcessedContent[]) => {
const manifest = {
...options.metadata,
generatedAt: new Date().toISOString(),
pages: content.map(([_tree, vfile]) => {
const frontmatter = (vfile.data?.frontmatter ?? {}) as {
title?: string;
tags?: string[];
[key: string]: unknown;
};
return {
slug: vfile.data?.slug ?? null,
title: frontmatter.title ?? null,
tags: frontmatter.tags ?? null,
filePath: vfile.data?.filePath ?? null,
frontmatter: options.includeFrontmatter ? frontmatter : undefined,
};
}),
};
let json = `${JSON.stringify(manifest, null, 2)}\n`;
if (options.transformManifest) {
json = options.transformManifest(json);
}
const output = await writeFile(
ctx.argv.output,
options.manifestSlug as FullSlug,
".json",
json,
);
return [output];
};
return {
name: "ExampleEmitter",
async emit(ctx, content, _resources) {
return emitManifest(ctx, content);
},
async *partialEmit(ctx, content, _resources, _changeEvents) {
const outputPaths = await emitManifest(ctx, content);
for (const outputPath of outputPaths) {
yield outputPath;
}
},
};
};

View file

@ -1,55 +0,0 @@
import type { QuartzFilterPlugin } from "@jackyzha0/quartz/plugins/types";
import type { ProcessedContent } from "@jackyzha0/quartz/plugins/vfile";
import type { BuildCtx } from "@jackyzha0/quartz/util/ctx";
import type { ExampleFilterOptions } from "./types";
const defaultOptions: ExampleFilterOptions = {
allowDrafts: false,
excludeTags: ["private"],
excludePathPrefixes: ["_drafts/", "_private/"],
};
const normalizeTag = (tag: unknown) => (typeof tag === "string" ? tag.trim().toLowerCase() : "");
const includesTag = (tags: unknown, excludedTags: string[]) => {
if (!Array.isArray(tags)) {
return false;
}
const normalizedExcluded = excludedTags.map((tag) => tag.toLowerCase());
return tags.some((tag) => normalizedExcluded.includes(normalizeTag(tag)));
};
/**
* Example filter that removes drafts, tagged pages, and excluded path prefixes.
*/
export const ExampleFilter: QuartzFilterPlugin<Partial<ExampleFilterOptions>> = (
userOptions?: Partial<ExampleFilterOptions>,
) => {
const options = { ...defaultOptions, ...userOptions };
return {
name: "ExampleFilter",
shouldPublish(_ctx: BuildCtx, [_tree, vfile]: ProcessedContent) {
const frontmatter = (vfile.data?.frontmatter ?? {}) as {
draft?: boolean | string;
tags?: string[];
};
const isDraft = frontmatter.draft === true || frontmatter.draft === "true";
if (isDraft && !options.allowDrafts) {
return false;
}
if (includesTag(frontmatter.tags, options.excludeTags)) {
return false;
}
const filePath = typeof vfile.data?.filePath === "string" ? vfile.data.filePath : "";
const normalizedPath = filePath.replace(/\\/g, "/");
if (options.excludePathPrefixes.some((prefix) => normalizedPath.startsWith(prefix))) {
return false;
}
return true;
},
};
};

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,23 +1,19 @@
export { ExampleTransformer } from "./transformer";
export { ExampleFilter } from "./filter";
export { ExampleEmitter } from "./emitter";
export { default as ExampleComponent } from "./components/ExampleComponent";
export {
simplifySlug,
getFullSlug,
getFullSlugFromUrl,
joinSegments,
resolvePath,
endsWith,
trimSuffix,
stripSlashes,
getFileExtension,
isFolderPath,
getAllSegmentPrefixes,
} from "./path.js";
export type {
ExampleTransformerOptions,
ExampleFilterOptions,
ExampleEmitterOptions,
} from "./types";
export type { FullSlug, SimpleSlug, RelativeURL } from "./path.js";
export type { ExampleComponentOptions } from "./components/ExampleComponent";
export { removeAllChildren, registerEscapeHandler, normalizeRelativeURLs } from "./dom.js";
// Re-export shared types from @quartz-community/types
export type {
QuartzComponent,
QuartzComponentProps,
QuartzComponentConstructor,
StringResource,
QuartzTransformerPlugin,
QuartzFilterPlugin,
QuartzEmitterPlugin,
} from "@quartz-community/types";
export { classNames } from "./lang.js";

3
src/lang.ts Normal file
View file

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

214
src/path.ts Normal file
View file

@ -0,0 +1,214 @@
/**
* Path utilities for Quartz plugins.
* These functions are isomorphic and work in both Node.js and browser environments.
*/
/**
* Branded string types for type-safe slug handling.
*/
type SlugLike<T> = string & { __brand: T };
/** Full slug - cannot be relative, may not have leading/trailing slashes, can have 'index' as last segment */
export type FullSlug = SlugLike<"full">;
/** Simple slug - no '/index' ending, no file extension, can have trailing slash for folders */
export type SimpleSlug = SlugLike<"simple">;
/** Relative URL - starts with './' or '../', used for navigation */
export type RelativeURL = SlugLike<"relative">;
/**
* Simplifies a full slug by removing the '/index' suffix.
* @param fp - The full slug to simplify
* @returns The simplified slug, or "/" if the result would be empty
*
* @example
* simplifySlug("folder/index") // "folder/"
* simplifySlug("page") // "page"
* simplifySlug("index") // "/"
*/
export function simplifySlug(fp: FullSlug | string): SimpleSlug {
const res = stripSlashes(trimSuffix(fp, "index"), true);
return (res.length === 0 ? "/" : res) as SimpleSlug;
}
/**
* Gets the current page's full slug from the window location.
* @param window - The window object (for browser environments)
* @returns The full slug of the current page
*
* @example
* // On page /blog/my-post
* getFullSlug(window) // "blog/my-post"
*/
export function getFullSlug(window: Window): FullSlug {
const res = window.document.body.dataset.slug! as FullSlug;
return res;
}
/**
* Gets the current page's full slug from window.location.pathname.
* Use this when document.body.dataset.slug is not available (e.g., in inline scripts).
* @returns The full slug derived from the URL pathname
*
* @example
* // On URL /blog/my-post/
* getFullSlugFromUrl() // "blog/my-post"
*/
export function getFullSlugFromUrl(): FullSlug {
let rawSlug = window.location.pathname;
if (rawSlug.endsWith("/")) rawSlug = rawSlug.slice(0, -1);
if (rawSlug.startsWith("/")) rawSlug = rawSlug.slice(1);
return rawSlug as FullSlug;
}
/**
* Joins path segments together, handling slashes properly.
* @param args - Path segments to join
* @returns The joined path
*
* @example
* joinSegments("a", "b", "c") // "a/b/c"
* joinSegments("/a/", "/b/", "c") // "/a/b/c"
* joinSegments("a", "", "c") // "a/c"
*/
export function joinSegments(...args: string[]): string {
if (args.length === 0) {
return "";
}
let joined = args
.filter((segment) => segment !== "" && segment !== "/")
.map((segment) => stripSlashes(segment))
.join("/");
const first = args[0];
const last = args[args.length - 1];
if (first?.startsWith("/")) {
joined = "/" + joined;
}
if (last?.endsWith("/")) {
joined = joined + "/";
}
return joined;
}
/**
* Resolves a path, ensuring it starts with a slash for absolute navigation.
* @param to - The target path
* @returns The resolved absolute path
*
* @example
* resolvePath("blog/post") // "/blog/post"
* resolvePath("/already-absolute") // "/already-absolute"
*/
export function resolvePath(to: string): string {
if (to.startsWith("/")) return to;
return "/" + to;
}
/**
* Checks if a string ends with a given suffix, accounting for path separators.
* @param s - The string to check
* @param suffix - The suffix to look for
* @returns True if the string ends with the suffix
*
* @example
* endsWith("folder/index", "index") // true
* endsWith("index", "index") // true
* endsWith("myindex", "index") // false
*/
export function endsWith(s: string, suffix: string): boolean {
return s === suffix || s.endsWith("/" + suffix);
}
/**
* Removes a suffix from a string if it ends with that suffix (respecting path separators).
* @param s - The string to trim
* @param suffix - The suffix to remove
* @returns The trimmed string
*
* @example
* trimSuffix("folder/index", "index") // "folder/"
* trimSuffix("page", "index") // "page"
*/
export function trimSuffix(s: string, suffix: string): string {
if (endsWith(s, suffix)) {
s = s.slice(0, -suffix.length);
}
return s;
}
/**
* Strips leading and/or trailing slashes from a string.
* @param s - The string to strip
* @param onlyStripPrefix - If true, only strip leading slash
* @returns The stripped string
*
* @example
* stripSlashes("/path/to/file/") // "path/to/file"
* stripSlashes("/path/", true) // "path/"
*/
export function stripSlashes(s: string, onlyStripPrefix?: boolean): string {
if (s.startsWith("/")) {
s = s.substring(1);
}
if (!onlyStripPrefix && s.endsWith("/")) {
s = s.slice(0, -1);
}
return s;
}
/**
* Gets the file extension from a path.
* @param s - The path string
* @returns The file extension including the dot, or undefined if none
*
* @example
* getFileExtension("file.md") // ".md"
* getFileExtension("file") // undefined
*/
export function getFileExtension(s: string): string | undefined {
return s.match(/\.[A-Za-z0-9]+$/)?.[0];
}
/**
* Checks if a path represents a folder (ends with /, index, index.md, or index.html).
* @param fplike - The path-like string to check
* @returns True if the path represents a folder
*
* @example
* isFolderPath("folder/") // true
* isFolderPath("folder/index") // true
* isFolderPath("file.md") // false
*/
export function isFolderPath(fplike: string): boolean {
return (
fplike.endsWith("/") ||
endsWith(fplike, "index") ||
endsWith(fplike, "index.md") ||
endsWith(fplike, "index.html")
);
}
/**
* Gets all segment prefixes for a path (useful for tags/breadcrumbs).
* @param path - The path string (e.g., "a/b/c")
* @returns Array of all prefixes (e.g., ["a", "a/b", "a/b/c"])
*
* @example
* getAllSegmentPrefixes("programming/web/react") // ["programming", "programming/web", "programming/web/react"]
*/
export function getAllSegmentPrefixes(path: string): string[] {
const segments = path.split("/");
const results: string[] = [];
for (let i = 0; i < segments.length; i++) {
results.push(segments.slice(0, i + 1).join("/"));
}
return results;
}

View file

@ -1,104 +0,0 @@
import type { PluggableList, Plugin } from "unified";
import type { Root as MdastRoot } from "mdast";
import type { Root as HastRoot, Element } from "hast";
import type { VFile } from "vfile";
import remarkGfm from "remark-gfm";
import rehypeSlug from "rehype-slug";
import { findAndReplace } from "mdast-util-find-and-replace";
import { visit } from "unist-util-visit";
import type { QuartzTransformerPlugin } from "@jackyzha0/quartz/plugins/types";
import type { BuildCtx } from "@jackyzha0/quartz/util/ctx";
import type { ExampleTransformerOptions } from "./types";
const defaultOptions: ExampleTransformerOptions = {
highlightToken: "==",
headingClass: "example-plugin-heading",
enableGfm: true,
addHeadingSlugs: true,
};
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const remarkHighlightToken = (token: string): Plugin<[], MdastRoot> => {
const escapedToken = escapeRegExp(token);
const pattern = new RegExp(`${escapedToken}([^\n]+?)${escapedToken}`, "g");
return () => (tree: MdastRoot, _file: VFile) => {
findAndReplace(tree, [
[
pattern,
(_match: string, value: string) => ({
type: "strong",
children: [{ type: "text", value }],
}),
],
]);
};
};
const rehypeHeadingClass = (className: string): Plugin<[], HastRoot> => {
return () => (tree: HastRoot, _file: VFile) => {
visit(tree, "element", (node: Element) => {
if (!/^h[1-6]$/.test(node.tagName)) {
return;
}
const existing = node.properties?.className;
const classes: string[] = Array.isArray(existing)
? existing.filter((value): value is string => typeof value === "string")
: typeof existing === "string"
? [existing]
: [];
node.properties = {
...node.properties,
className: [...classes, className],
};
});
};
};
/**
* Example transformer showing remark/rehype usage and resource injection.
*/
export const ExampleTransformer: QuartzTransformerPlugin<Partial<ExampleTransformerOptions>> = (
userOptions?: Partial<ExampleTransformerOptions>,
) => {
const options = { ...defaultOptions, ...userOptions };
return {
name: "ExampleTransformer",
textTransform(_ctx: BuildCtx, src: string) {
return src.endsWith("\n") ? src : `${src}\n`;
},
markdownPlugins(): PluggableList {
const plugins: PluggableList = [remarkHighlightToken(options.highlightToken)];
if (options.enableGfm) {
plugins.unshift(remarkGfm);
}
return plugins;
},
htmlPlugins(): PluggableList {
const plugins: PluggableList = [rehypeHeadingClass(options.headingClass)];
if (options.addHeadingSlugs) {
plugins.unshift(rehypeSlug);
}
return plugins;
},
externalResources() {
return {
css: [
{
content: `.${options.headingClass} { letter-spacing: 0.02em; }`,
inline: true,
},
],
js: [
{
contentType: "inline",
loadTime: "afterDOMReady",
script: "document.documentElement.dataset.exampleTransformer = 'true'",
},
],
additionalHead: [],
};
},
};
};

View file

@ -1,54 +0,0 @@
export type {
ChangeEvent,
QuartzEmitterPlugin,
QuartzEmitterPluginInstance,
QuartzFilterPlugin,
QuartzFilterPluginInstance,
QuartzTransformerPlugin,
QuartzTransformerPluginInstance,
} from "@jackyzha0/quartz/plugins/types";
export type { ProcessedContent, QuartzPluginData } from "@jackyzha0/quartz/plugins/vfile";
export type { BuildCtx } from "@jackyzha0/quartz/util/ctx";
export type { CSSResource, JSResource, StaticResources } from "@jackyzha0/quartz/util/resources";
export interface ExampleTransformerOptions {
/** Token used to highlight text, defaults to ==highlight== */
highlightToken: string;
/** Add a CSS class to all headings in the rendered HTML. */
headingClass: string;
/** Enable remark-gfm for tables/task lists. */
enableGfm: boolean;
/** Enable adding slug IDs to headings. */
addHeadingSlugs: boolean;
}
export interface ExampleFilterOptions {
/** Allow pages marked draft: true to publish. */
allowDrafts: boolean;
/** Exclude pages that contain any of these frontmatter tags. */
excludeTags: string[];
/** Exclude paths that start with any of these prefixes (relative to content root). */
excludePathPrefixes: string[];
}
export interface ExampleEmitterOptions {
/** Filename to emit at the site root. */
manifestSlug: string;
/** Whether to include the frontmatter block in the manifest. */
includeFrontmatter: boolean;
/** Extra metadata to write at the top level of the manifest. */
metadata: Record<string, unknown>;
/** Optional hook to transform the emitted manifest JSON string. */
transformManifest?: (json: string) => string;
/** Add a custom class to the emitted manifest <script> tag if used in HTML. */
manifestScriptClass?: string;
}
export interface ExampleComponentOptions {
/** Text to prefix before the title */
prefix?: string;
/** Text to suffix after the title */
suffix?: string;
/** CSS class name to apply */
className?: string;
}

View file

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

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

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: { draft: true } });
expect(filter.shouldPublish(ctx, content)).toBe(false);
});
it("allows drafts when configured", () => {
const ctx = createCtx();
const filter = ExampleFilter({ allowDrafts: true });
const content = createProcessedContent({ frontmatter: { draft: true } });
expect(filter.shouldPublish(ctx, content)).toBe(true);
});
});

View file

@ -1,38 +0,0 @@
import type { BuildCtx } from "@jackyzha0/quartz/util/ctx";
import type { QuartzConfig } from "@jackyzha0/quartz/cfg";
import type { ProcessedContent, QuartzPluginData } from "@jackyzha0/quartz/plugins/vfile";
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];
};

107
test/path.test.ts Normal file
View file

@ -0,0 +1,107 @@
import { describe, it, expect } from "vitest";
import {
simplifySlug,
joinSegments,
resolvePath,
endsWith,
trimSuffix,
stripSlashes,
isFolderPath,
getAllSegmentPrefixes,
} from "../src/path.js";
describe("simplifySlug", () => {
it("removes /index suffix", () => {
expect(simplifySlug("folder/index")).toBe("folder/");
});
it("returns / for index only", () => {
expect(simplifySlug("index")).toBe("/");
});
it("keeps non-index slugs unchanged", () => {
expect(simplifySlug("page")).toBe("page");
});
});
describe("joinSegments", () => {
it("joins simple segments", () => {
expect(joinSegments("a", "b", "c")).toBe("a/b/c");
});
it("handles leading slash", () => {
expect(joinSegments("/a", "b")).toBe("/a/b");
});
it("handles trailing slash", () => {
expect(joinSegments("a", "b/")).toBe("a/b/");
});
it("filters empty segments", () => {
expect(joinSegments("a", "", "c")).toBe("a/c");
});
});
describe("resolvePath", () => {
it("adds leading slash if missing", () => {
expect(resolvePath("blog/post")).toBe("/blog/post");
});
it("keeps existing leading slash", () => {
expect(resolvePath("/already")).toBe("/already");
});
});
describe("endsWith", () => {
it("matches exact suffix", () => {
expect(endsWith("folder/index", "index")).toBe(true);
});
it("matches exact string", () => {
expect(endsWith("index", "index")).toBe(true);
});
it("does not match partial suffix", () => {
expect(endsWith("myindex", "index")).toBe(false);
});
});
describe("trimSuffix", () => {
it("removes matching suffix", () => {
expect(trimSuffix("folder/index", "index")).toBe("folder/");
});
it("keeps non-matching string", () => {
expect(trimSuffix("page", "index")).toBe("page");
});
});
describe("stripSlashes", () => {
it("removes leading and trailing slashes", () => {
expect(stripSlashes("/path/to/file/")).toBe("path/to/file");
});
it("removes only leading slash when specified", () => {
expect(stripSlashes("/path/", true)).toBe("path/");
});
});
describe("isFolderPath", () => {
it("detects trailing slash", () => {
expect(isFolderPath("folder/")).toBe(true);
});
it("detects index suffix", () => {
expect(isFolderPath("folder/index")).toBe(true);
});
it("returns false for files", () => {
expect(isFolderPath("file.md")).toBe(false);
});
});
describe("getAllSegmentPrefixes", () => {
it("returns all prefixes", () => {
expect(getAllSegmentPrefixes("a/b/c")).toEqual(["a", "a/b", "a/b/c"]);
});
});

View file

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

View file

@ -3,8 +3,9 @@ import { defineConfig } from "tsup";
export default defineConfig({
entry: {
index: "src/index.ts",
types: "src/types.ts",
"components/index": "src/components/index.ts",
path: "src/path.ts",
dom: "src/dom.ts",
lang: "src/lang.ts",
},
format: ["esm"],
dts: true,
@ -14,32 +15,4 @@ export default defineConfig({
target: "es2022",
splitting: false,
outDir: "dist",
esbuildOptions(options) {
options.jsx = "automatic";
options.jsxImportSource = "preact";
},
esbuildPlugins: [
{
name: "text-loader",
setup(build) {
build.onLoad({ filter: /\.scss$/ }, async (args) => {
const fs = await import("fs");
const text = await fs.promises.readFile(args.path, "utf8");
return {
contents: text,
loader: "text",
};
});
build.onLoad({ filter: /\.inline\.ts$/ }, async (args) => {
const fs = await import("fs");
const text = await fs.promises.readFile(args.path, "utf8");
return {
contents: text,
loader: "text",
};
});
},
},
],
});

View file

@ -1,150 +0,0 @@
declare module "@jackyzha0/quartz/components/types" {
export type QuartzComponent = unknown;
}
declare module "@jackyzha0/quartz/util/path" {
export type FilePath = string & { _brand: "FilePath" };
export type FullSlug = string & { _brand: "FullSlug" };
export function joinSegments(...segments: string[]): FilePath;
}
declare module "@jackyzha0/quartz/util/resources" {
export type JSResource =
| {
loadTime: "beforeDOMReady" | "afterDOMReady";
moduleType?: "module";
spaPreserve?: boolean;
src: string;
contentType: "external";
}
| {
loadTime: "beforeDOMReady" | "afterDOMReady";
moduleType?: "module";
spaPreserve?: boolean;
script: string;
contentType: "inline";
};
export type CSSResource = {
content: string;
inline?: boolean;
spaPreserve?: boolean;
};
export interface StaticResources {
css: CSSResource[];
js: JSResource[];
additionalHead: unknown[];
}
}
declare module "@jackyzha0/quartz/util/ctx" {
import type { QuartzConfig } from "@jackyzha0/quartz/cfg";
import type { FilePath, FullSlug } from "@jackyzha0/quartz/util/path";
export interface Argv {
directory: string;
verbose: boolean;
output: string;
serve: boolean;
watch: boolean;
port: number;
wsPort: number;
remoteDevHost?: string;
concurrency?: number;
}
export interface BuildCtx {
buildId: string;
argv: Argv;
cfg: QuartzConfig;
allSlugs: FullSlug[];
allFiles: FilePath[];
incremental: boolean;
}
}
declare module "@jackyzha0/quartz/cfg" {
export interface QuartzConfig {
configuration: {
baseUrl?: string;
};
}
}
declare module "@jackyzha0/quartz/plugins/vfile" {
import type { Root as HtmlRoot } from "hast";
import type { Root as MdRoot } from "mdast";
import type { Data, VFile } from "vfile";
export type QuartzPluginData = Data;
export type MarkdownContent = [MdRoot, VFile];
export type ProcessedContent = [HtmlRoot, VFile];
}
declare module "@jackyzha0/quartz/plugins/types" {
import type { PluggableList } from "unified";
import type { StaticResources } from "@jackyzha0/quartz/util/resources";
import type { ProcessedContent } from "@jackyzha0/quartz/plugins/vfile";
import type { QuartzComponent } from "@jackyzha0/quartz/components/types";
import type { FilePath } from "@jackyzha0/quartz/util/path";
import type { BuildCtx } from "@jackyzha0/quartz/util/ctx";
import type { VFile } from "vfile";
export interface PluginTypes {
transformers: QuartzTransformerPluginInstance[];
filters: QuartzFilterPluginInstance[];
emitters: QuartzEmitterPluginInstance[];
}
type OptionType = object | undefined;
type ExternalResourcesFn = (ctx: BuildCtx) => Partial<StaticResources> | undefined;
export type QuartzTransformerPlugin<Options extends OptionType = undefined> = (
opts?: Options,
) => QuartzTransformerPluginInstance;
export type QuartzTransformerPluginInstance = {
name: string;
textTransform?: (ctx: BuildCtx, src: string) => string;
markdownPlugins?: (ctx: BuildCtx) => PluggableList;
htmlPlugins?: (ctx: BuildCtx) => PluggableList;
externalResources?: ExternalResourcesFn;
};
export type QuartzFilterPlugin<Options extends OptionType = undefined> = (
opts?: Options,
) => QuartzFilterPluginInstance;
export type QuartzFilterPluginInstance = {
name: string;
shouldPublish(ctx: BuildCtx, content: ProcessedContent): boolean;
};
export type ChangeEvent = {
type: "add" | "change" | "delete";
path: FilePath;
file?: VFile;
};
export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (
opts?: Options,
) => QuartzEmitterPluginInstance;
export type QuartzEmitterPluginInstance = {
name: string;
emit: (
ctx: BuildCtx,
content: ProcessedContent[],
resources: StaticResources,
) => Promise<FilePath[]> | AsyncGenerator<FilePath>;
partialEmit?: (
ctx: BuildCtx,
content: ProcessedContent[],
resources: StaticResources,
changeEvents: ChangeEvent[],
) => Promise<FilePath[]> | AsyncGenerator<FilePath> | null;
getQuartzComponents?: (ctx: BuildCtx) => QuartzComponent[];
externalResources?: ExternalResourcesFn;
};
}