feat(plugins): breadcrumbs as community plugin

Implement breadcrumbs component with file trie-based path resolution,
configurable separators, and i18n support. Uses direct imports from
@quartz-community/types instead of ambient declaration files.
This commit is contained in:
saberzero1 2026-02-13 13:18:55 +01:00
parent d8f77226b9
commit a29f753827
No known key found for this signature in database
27 changed files with 1175 additions and 1720 deletions

1889
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/breadcrumbs",
"version": "0.1.0",
"description": "Template repository for Quartz community plugins.",
"description": "Breadcrumbs navigation component for Quartz",
"type": "module",
"license": "MIT",
"author": "Quartz Community",
"homepage": "https://quartz.jzhao.xyz",
"homepage": "https://github.com/quartz-community/breadcrumbs",
"repository": {
"type": "git",
"url": "https://github.com/quartz-community/plugin-template"
"url": "https://github.com/quartz-community/breadcrumbs"
},
"keywords": [
"quartz",
"quartz-plugin",
"plugin-template",
"remark",
"rehype",
"mdast",
"hast"
"breadcrumbs",
"navigation",
"component"
],
"files": [
"dist",
@ -30,10 +28,6 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./types": {
"types": "./dist/types.d.ts",
"import": "./dist/types.js"
},
"./components": {
"types": "./dist/components/index.d.ts",
"import": "./dist/components/index.js"
@ -66,15 +60,8 @@
}
},
"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"
"@quartz-community/types": "file:../types",
"@quartz-community/utils": "github:quartz-community/utils"
},
"devDependencies": {
"@types/hast": "^3.0.4",
@ -86,8 +73,10 @@
"eslint-config-prettier": "^9.1.0",
"preact": "^10.28.2",
"prettier": "^3.6.2",
"sass-embedded": "^1.79.5",
"tsup": "^8.5.0",
"typescript": "^5.9.3",
"vfile": "^6.0.3",
"vitest": "^2.1.9"
},
"engines": {

View file

@ -0,0 +1,97 @@
import type { QuartzComponent, QuartzComponentProps } from "@quartz-community/types";
import { classNames } from "../util/lang";
import { resolveRelative, simplifySlug } from "../util/path";
import { FileTrieNode, trieFromAllFiles } from "../util/fileTrie";
import style from "./styles/breadcrumbs.scss";
type QuartzComponentConstructor<Options extends object | undefined = undefined> = (
opts: Options,
) => QuartzComponent;
type CrumbData = {
displayName: string;
path: string;
};
export interface BreadcrumbOptions {
/** Symbol between crumbs */
spacerSymbol: string;
/** Name of first crumb */
rootName: string;
/** Whether to look up frontmatter title for folders */
resolveFrontmatterTitle: boolean;
/** Whether to display the current page in the breadcrumbs */
showCurrentPage: boolean;
}
const defaultOptions: BreadcrumbOptions = {
spacerSymbol: "",
rootName: "Home",
resolveFrontmatterTitle: true,
showCurrentPage: true,
};
function formatCrumb(displayName: string, baseSlug: string, currentSlug: string): CrumbData {
return {
displayName: displayName.replaceAll("-", " "),
path: resolveRelative(baseSlug, currentSlug),
};
}
export default ((opts?: Partial<BreadcrumbOptions>) => {
const options: BreadcrumbOptions = { ...defaultOptions, ...opts };
const Breadcrumbs: QuartzComponent = ({
fileData,
allFiles,
displayClass,
ctx,
}: QuartzComponentProps) => {
const typedCtx = (ctx ?? {}) as Record<string, unknown> as { trie?: FileTrieNode };
typedCtx.trie ??= trieFromAllFiles(
allFiles as Array<{
slug?: string;
filePath?: string;
frontmatter?: { title?: string; [key: string]: unknown };
}>,
);
const trie = typedCtx.trie;
const slug = fileData.slug as string;
const slugParts = slug.split("/");
const pathNodes = trie.ancestryChain(slugParts);
if (!pathNodes) {
return null;
}
const crumbs: CrumbData[] = pathNodes.map((node, idx) => {
const crumb = formatCrumb(node.displayName, slug, simplifySlug(node.slug));
if (idx === 0) {
crumb.displayName = options.rootName;
}
if (idx === pathNodes.length - 1) {
crumb.path = "";
}
return crumb;
});
if (!options.showCurrentPage) {
crumbs.pop();
}
return (
<nav class={classNames(displayClass, "breadcrumb-container")} aria-label="breadcrumbs">
{crumbs.map((crumb, index) => (
<div class="breadcrumb-element">
<a href={crumb.path}>{crumb.displayName}</a>
{index !== crumbs.length - 1 && <p>{` ${options.spacerSymbol} `}</p>}
</div>
))}
</nav>
);
};
Breadcrumbs.css = style;
return Breadcrumbs;
}) satisfies QuartzComponentConstructor;

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 +1,2 @@
export { default as ExampleComponent } from "./ExampleComponent";
export type { ExampleComponentOptions } from "./ExampleComponent";
export { default as Breadcrumbs } from "./Breadcrumbs";
export type { BreadcrumbOptions } from "./Breadcrumbs";

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

@ -0,0 +1,22 @@
.breadcrumb-container {
margin: 0;
margin-top: 0.75rem;
padding: 0;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 0.5rem;
}
.breadcrumb-element {
p {
margin: 0;
margin-left: 0.5rem;
padding: 0;
line-height: normal;
}
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}

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,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,9 @@
export { ExampleTransformer } from "./transformer";
export { ExampleFilter } from "./filter";
export { ExampleEmitter } from "./emitter";
export { default as ExampleComponent } from "./components/ExampleComponent";
export type {
ExampleTransformerOptions,
ExampleFilterOptions,
ExampleEmitterOptions,
} from "./types";
export type { ExampleComponentOptions } from "./components/ExampleComponent";
export { default as Breadcrumbs } from "./components/Breadcrumbs";
export type { BreadcrumbOptions } from "./components/Breadcrumbs";
// Re-export shared types from @quartz-community/types
export type {
QuartzComponent,
QuartzComponentProps,
QuartzComponentConstructor,
StringResource,
QuartzTransformerPlugin,
QuartzFilterPlugin,
QuartzEmitterPlugin,
} from "@quartz-community/types";

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 +1,10 @@
export type {
ChangeEvent,
QuartzEmitterPlugin,
QuartzEmitterPluginInstance,
QuartzFilterPlugin,
QuartzFilterPluginInstance,
BuildCtx,
CSSResource,
JSResource,
ProcessedContent,
QuartzPluginData,
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;
}
StaticResources,
} from "@quartz-community/types";

155
src/util/fileTrie.ts Normal file
View file

@ -0,0 +1,155 @@
import { joinSegments } from "@quartz-community/utils";
interface FileTrieData {
slug: string;
title: string;
filePath: string;
}
export class FileTrieNode<T extends FileTrieData = FileTrieData> {
isFolder: boolean;
children: Array<FileTrieNode<T>>;
private slugSegments: string[];
private fileSegmentHint?: string;
private displayNameOverride?: string;
data: T | null;
constructor(segments: string[], data?: T) {
this.children = [];
this.slugSegments = segments;
this.data = data ?? null;
this.isFolder = false;
this.displayNameOverride = undefined;
}
get displayName(): string {
const nonIndexTitle = this.data?.title === "index" ? undefined : this.data?.title;
return (
this.displayNameOverride ?? nonIndexTitle ?? this.fileSegmentHint ?? this.slugSegment ?? ""
);
}
set displayName(name: string) {
this.displayNameOverride = name;
}
get slug(): string {
const path = joinSegments(...this.slugSegments);
if (this.isFolder) {
return joinSegments(path, "index");
}
return path;
}
get slugSegment(): string {
return this.slugSegments[this.slugSegments.length - 1] ?? "";
}
private makeChild(path: string[], file?: T): FileTrieNode<T> {
const nextSegment = path[0];
if (!nextSegment) {
throw new Error("path is empty");
}
const fullPath = [...this.slugSegments, nextSegment];
const child = new FileTrieNode<T>(fullPath, file);
this.children.push(child);
return child;
}
private insert(path: string[], file: T): void {
if (path.length === 0) {
throw new Error("path is empty");
}
this.isFolder = true;
const segment = path[0];
if (!segment) {
throw new Error("path is empty");
}
if (path.length === 1) {
if (segment === "index") {
this.data ??= file;
} else {
this.makeChild(path, file);
}
} else if (path.length > 1) {
const child =
this.children.find((c) => c.slugSegment === segment) ?? this.makeChild(path, undefined);
const fileParts = file.filePath.split("/");
const hint = fileParts.at(-path.length);
if (hint) {
child.fileSegmentHint = hint;
}
child.insert(path.slice(1), file);
}
}
add(file: T): void {
this.insert(file.slug.split("/"), file);
}
findNode(path: string[]): FileTrieNode<T> | undefined {
if (path.length === 0 || (path.length === 1 && path[0] === "index")) {
return this;
}
return this.children.find((c) => c.slugSegment === path[0])?.findNode(path.slice(1));
}
ancestryChain(path: string[]): Array<FileTrieNode<T>> | undefined {
if (path.length === 0 || (path.length === 1 && path[0] === "index")) {
return [this];
}
const child = this.children.find((c) => c.slugSegment === path[0]);
if (!child) {
return undefined;
}
const childPath = child.ancestryChain(path.slice(1));
if (!childPath) {
return undefined;
}
return [this, ...childPath];
}
filter(filterFn: (node: FileTrieNode<T>) => boolean): void {
this.children = this.children.filter(filterFn);
this.children.forEach((child) => child.filter(filterFn));
}
map(mapFn: (node: FileTrieNode<T>) => void): void {
mapFn(this);
this.children.forEach((child) => child.map(mapFn));
}
sort(sortFn: (a: FileTrieNode<T>, b: FileTrieNode<T>) => number): void {
this.children = this.children.sort(sortFn);
this.children.forEach((e) => e.sort(sortFn));
}
}
export function trieFromAllFiles(
allFiles: Array<{
slug?: string;
filePath?: string;
frontmatter?: { title?: string; [key: string]: unknown };
}>,
): FileTrieNode {
const trie = new FileTrieNode([]);
allFiles.forEach((file) => {
if (file.frontmatter) {
trie.add({
slug: file.slug!,
title: file.frontmatter.title ?? "",
filePath: file.filePath!,
});
}
});
return trie;
}

View file

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

26
src/util/path.ts Normal file
View file

@ -0,0 +1,26 @@
import { simplifySlug as utilSimplifySlug, joinSegments } from "@quartz-community/utils";
export function simplifySlug(fp: string): string {
return utilSimplifySlug(fp);
}
export function resolveRelative(current: string, target: string): string {
const simplified = simplifySlug(target);
const rootPath = pathToRoot(current);
return joinSegments(rootPath, simplified);
}
function pathToRoot(slug: string): string {
let rootPath = slug
.split("/")
.filter((x) => x !== "")
.slice(0, -1)
.map((_) => "..")
.join("/");
if (rootPath.length === 0) {
rootPath = ".";
}
return rootPath;
}

14
test/component.test.ts Normal file
View file

@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { Breadcrumbs } from "../src/index";
describe("Breadcrumbs", () => {
it("is exported as a function", () => {
expect(typeof Breadcrumbs).toBe("function");
});
it("returns a component with css property", () => {
const component = Breadcrumbs();
expect(typeof component).toBe("function");
expect(typeof component.css).toBe("string");
});
});

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,6 +1,9 @@
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 type {
BuildCtx,
QuartzConfig,
ProcessedContent,
QuartzPluginData,
} from "@quartz-community/types";
import { VFile } from "vfile";
type BuildCtxOverrides = Omit<Partial<BuildCtx>, "argv"> & {

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

@ -1,9 +1,9 @@
import { defineConfig } from "tsup";
import * as esbuild from "esbuild";
export default defineConfig({
entry: {
index: "src/index.ts",
types: "src/types.ts",
"components/index": "src/components/index.ts",
},
format: ["esm"],
@ -32,11 +32,19 @@ export default defineConfig({
});
build.onLoad({ filter: /\.inline\.ts$/ }, async (args) => {
const fs = await import("fs");
const text = await fs.promises.readFile(args.path, "utf8");
const result = await esbuild.build({
entryPoints: [args.path],
bundle: true,
write: false,
format: "iife",
target: "es2022",
minify: false,
platform: "browser",
});
const code = result.outputFiles?.[0]?.text ?? "";
return {
contents: text,
loader: "text",
contents: `export default ${JSON.stringify(code)};`,
loader: "ts",
};
});
},

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