feat(plugins): table of contents as community plugin

This commit is contained in:
saberzero1 2026-02-09 23:18:16 +01:00
parent 8a32f7fcee
commit f0eda3263f
No known key found for this signature in database
25 changed files with 397 additions and 1662 deletions

1144
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/table-of-contents",
"version": "0.1.0",
"description": "Template repository for Quartz community plugins.",
"description": "Table of Contents component for Quartz",
"type": "module",
"license": "MIT",
"author": "Quartz Community",
"homepage": "https://quartz.jzhao.xyz",
"homepage": "https://github.com/quartz-community/table-of-contents",
"repository": {
"type": "git",
"url": "https://github.com/quartz-community/plugin-template"
"url": "https://github.com/quartz-community/table-of-contents"
},
"keywords": [
"quartz",
"quartz-plugin",
"plugin-template",
"remark",
"rehype",
"mdast",
"hast"
"table-of-contents",
"toc",
"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,19 +60,9 @@
}
},
"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": "github:quartz-community/types"
},
"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",

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

@ -0,0 +1,51 @@
import type { JSX } from "preact";
const OverflowList = ({
children,
...props
}: JSX.HTMLAttributes<HTMLUListElement> & { id: string }) => {
return (
<ul {...props} class={[props.class, "overflow"].filter(Boolean).join(" ")} id={props.id}>
{children}
<li class="overflow-end" />
</ul>
);
};
let numLists = 0;
export default () => {
const id = `list-${numLists++}`;
return {
OverflowList: (props: JSX.HTMLAttributes<HTMLUListElement>) => (
<OverflowList {...props} id={id} />
),
overflowListAfterDOMLoaded: `
document.addEventListener("nav", (e) => {
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
const parentUl = entry.target.parentElement
if (!parentUl) return
if (entry.isIntersecting) {
parentUl.classList.remove("gradient-active")
} else {
parentUl.classList.add("gradient-active")
}
}
})
const ul = document.getElementById("${id}")
if (!ul) return
const end = ul.querySelector(".overflow-end")
if (!end) return
observer.observe(end)
const cleanup = () => observer.disconnect()
if (window.addCleanup) {
window.addCleanup(cleanup)
}
})
`,
};
};

View file

@ -0,0 +1,117 @@
import type {
QuartzComponent,
QuartzComponentProps,
QuartzComponentFunction,
} from "@quartz-community/types";
import { classNames } from "../util/lang";
import { i18n } from "../i18n";
import legacyStyle from "./styles/legacyToc.scss";
import modernStyle from "./styles/toc.scss";
// @ts-expect-error - inline script imported as string by esbuild loader
import script from "./scripts/toc.inline.ts";
import OverflowListFactory from "./OverflowList";
import { concatenateResources } from "../util/resources";
interface Options {
layout: "modern" | "legacy";
}
const defaultOptions: Options = {
layout: "modern",
};
let numTocs = 0;
export default ((opts?: Partial<Options>) => {
const layout = opts?.layout ?? defaultOptions.layout;
const { OverflowList, overflowListAfterDOMLoaded } = OverflowListFactory();
const TableOfContents: QuartzComponent = (props: QuartzComponentProps) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { fileData, cfg } = props as any;
if (!fileData?.toc) {
return null;
}
const id = `toc-${numTocs++}`;
return (
<div class={classNames("toc")}>
<button
type="button"
class={fileData.collapseToc ? "collapsed toc-header" : "toc-header"}
aria-controls={id}
aria-expanded={!fileData.collapseToc}
>
<h3>{i18n(cfg.locale).components.tableOfContents.title}</h3>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="fold"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<OverflowList
id={id}
class={fileData.collapseToc ? "collapsed toc-content" : "toc-content"}
>
{fileData.toc.map((tocEntry: Record<string, unknown>) => {
const slug = String(tocEntry.slug);
const depth = String(tocEntry.depth);
const text = String(tocEntry.text);
return (
<li key={slug} class={`depth-${depth}`}>
<a href={`#${slug}`} data-for={slug}>
{text}
</a>
</li>
);
})}
</OverflowList>
</div>
);
};
TableOfContents.css = modernStyle;
TableOfContents.afterDOMLoaded = concatenateResources(
script,
overflowListAfterDOMLoaded,
) as string;
const LegacyTableOfContents: QuartzComponent = (props: QuartzComponentProps) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { fileData, cfg } = props as any;
if (!fileData?.toc) {
return null;
}
return (
<details class="toc" open={!fileData.collapseToc}>
<summary>
<h3>{i18n(cfg.locale).components.tableOfContents.title}</h3>
</summary>
<ul>
{fileData.toc.map((tocEntry: Record<string, unknown>) => {
const slug = String(tocEntry.slug);
const depth = String(tocEntry.depth);
const text = String(tocEntry.text);
return (
<li key={slug} class={`depth-${depth}`}>
<a href={`#${slug}`} data-for={slug}>
{text}
</a>
</li>
);
})}
</ul>
</details>
);
};
LegacyTableOfContents.css = legacyStyle;
return layout === "modern" ? TableOfContents : LegacyTableOfContents;
}) satisfies QuartzComponentFunction;

View file

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

View file

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

View file

@ -0,0 +1,50 @@
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
const slug = entry.target.id;
const tocEntryElements = document.querySelectorAll(`a[data-for="${slug}"]`);
const windowHeight = entry.rootBounds?.height;
if (windowHeight && tocEntryElements.length > 0) {
if (entry.boundingClientRect.y < windowHeight) {
tocEntryElements.forEach((tocEntryElement) => tocEntryElement.classList.add("in-view"));
} else {
tocEntryElements.forEach((tocEntryElement) => tocEntryElement.classList.remove("in-view"));
}
}
}
});
function toggleToc(this: HTMLElement) {
this.classList.toggle("collapsed");
this.setAttribute(
"aria-expanded",
this.getAttribute("aria-expanded") === "true" ? "false" : "true",
);
const content = this.nextElementSibling as HTMLElement | undefined;
if (!content) return;
content.classList.toggle("collapsed");
}
function setupToc() {
const tocElements = Array.from(document.getElementsByClassName("toc"));
for (const toc of tocElements) {
const button = toc.querySelector(".toc-header");
const content = toc.querySelector(".toc-content");
if (!button || !content) return;
button.addEventListener("click", toggleToc);
const cleanup = () => button.removeEventListener("click", toggleToc);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((window as any).addCleanup) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).addCleanup(cleanup);
}
}
}
document.addEventListener("nav", () => {
setupToc();
// update toc entry highlighting
observer.disconnect();
const headers = document.querySelectorAll("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]");
headers.forEach((header) => observer.observe(header));
});

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

@ -0,0 +1,27 @@
details.toc {
& summary {
cursor: pointer;
&::marker {
color: var(--dark);
}
& > * {
padding-left: 0.25rem;
display: inline-block;
margin: 0;
}
}
& ul {
list-style: none;
margin: 0.5rem 1.25rem;
padding: 0;
}
@for $i from 1 through 6 {
& .depth-#{$i} {
padding-left: calc(1rem * #{$i});
}
}
}

View file

@ -0,0 +1,64 @@
.toc {
display: flex;
flex-direction: column;
overflow-y: hidden;
min-height: 1.4rem;
flex: 0 0.5 auto;
&:has(button.toc-header.collapsed) {
flex: 0 1 1.4rem;
}
}
button.toc-header {
background-color: transparent;
border: none;
text-align: left;
cursor: pointer;
padding: 0;
color: var(--dark);
display: flex;
align-items: center;
& h3 {
font-size: 1rem;
display: inline-block;
margin: 0;
}
& .fold {
margin-left: 0.5rem;
transition: transform 0.3s ease;
opacity: 0.8;
}
&.collapsed .fold {
transform: rotateZ(-90deg);
}
}
ul.toc-content.overflow {
list-style: none;
position: relative;
margin: 0.5rem 0;
padding: 0;
max-height: calc(100% - 2rem);
overscroll-behavior: contain;
list-style: none;
& > li > a {
color: var(--dark);
opacity: 0.35;
transition:
0.5s ease opacity,
0.3s ease color;
&.in-view {
opacity: 0.75;
}
}
@for $i from 0 through 6 {
& .depth-#{$i} {
padding-left: calc(1rem * #{$i});
}
}
}

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,7 +1,7 @@
export default {
components: {
example: {
title: "Example",
tableOfContents: {
title: "Table of Contents",
},
},
};

View file

@ -1,23 +1,8 @@
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 TableOfContents } from "./components/TableOfContents";
// Re-export shared types from @quartz-community/types
export type {
QuartzComponent,
QuartzComponentProps,
QuartzComponentConstructor,
StringResource,
QuartzTransformerPlugin,
QuartzFilterPlugin,
QuartzEmitterPlugin,
QuartzComponentFunction,
} 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 +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 +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(" ");
}

7
src/util/resources.ts Normal file
View file

@ -0,0 +1,7 @@
export type StringResource = string | string[] | undefined;
export function concatenateResources(...resources: StringResource[]): StringResource {
return resources
.filter((resource): resource is string | string[] => resource !== undefined)
.flat();
}

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

49
test/toc.test.ts Normal file
View file

@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { concatenateResources } from "../src/util/resources";
import { classNames } from "../src/util/lang";
describe("TableOfContents Plugin", () => {
describe("concatenateResources", () => {
it("should concatenate string resources", () => {
const result = concatenateResources("a", "b", "c");
expect(result).toEqual(["a", "b", "c"]);
});
it("should handle undefined values", () => {
const result = concatenateResources("a", undefined, "b");
expect(result).toEqual(["a", "b"]);
});
it("should flatten arrays", () => {
const result = concatenateResources(["a", "b"], "c");
expect(result).toEqual(["a", "b", "c"]);
});
it("should return undefined for all undefined", () => {
const result = concatenateResources(undefined, undefined);
expect(result).toEqual([]);
});
});
describe("classNames", () => {
it("should join class names", () => {
const result = classNames("class1", "class2");
expect(result).toBe("class1 class2");
});
it("should handle display class", () => {
const result = classNames("mobile-only", "class1");
expect(result).toBe("mobile-only class1");
});
it("should filter falsy values", () => {
const result = classNames("class1", undefined, "class2");
expect(result).toBe("class1 class2");
});
it("should handle empty input", () => {
const result = classNames();
expect(result).toBe("");
});
});
});

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