mirror of
https://github.com/quartz-community/table-of-contents.git
synced 2026-07-22 02:50:28 +00:00
feat(plugins): table of contents as community plugin
This commit is contained in:
parent
8a32f7fcee
commit
f0eda3263f
25 changed files with 397 additions and 1662 deletions
1144
package-lock.json
generated
1144
package-lock.json
generated
File diff suppressed because it is too large
Load diff
32
package.json
32
package.json
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
51
src/components/OverflowList.tsx
Normal file
51
src/components/OverflowList.tsx
Normal 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)
|
||||
}
|
||||
})
|
||||
`,
|
||||
};
|
||||
};
|
||||
117
src/components/TableOfContents.tsx
Normal file
117
src/components/TableOfContents.tsx
Normal 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;
|
||||
|
|
@ -1,2 +1 @@
|
|||
export { default as ExampleComponent } from "./ExampleComponent";
|
||||
export type { ExampleComponentOptions } from "./ExampleComponent";
|
||||
export { default as TableOfContents } from "./TableOfContents";
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
document.addEventListener('nav', () => {
|
||||
console.log('ExampleComponent: page navigation occurred');
|
||||
});
|
||||
50
src/components/scripts/toc.inline.ts
Normal file
50
src/components/scripts/toc.inline.ts
Normal 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));
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
27
src/components/styles/legacyToc.scss
Normal file
27
src/components/styles/legacyToc.scss
Normal 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});
|
||||
}
|
||||
}
|
||||
}
|
||||
64
src/components/styles/toc.scss
Normal file
64
src/components/styles/toc.scss
Normal 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});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
export default {
|
||||
components: {
|
||||
example: {
|
||||
title: "Example",
|
||||
tableOfContents: {
|
||||
title: "Table of Contents",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
19
src/index.ts
19
src/index.ts
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
54
src/types.ts
54
src/types.ts
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
7
src/util/resources.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
49
test/toc.test.ts
Normal 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("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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**");
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
};
|
||||
});
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue