Implement og-image emitter plugin

This commit is contained in:
saberzero1 2026-02-13 23:53:16 +01:00
parent f1e2f11d59
commit 81045a698c
No known key found for this signature in database
25 changed files with 1346 additions and 1669 deletions

1636
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,23 +1,22 @@
{
"name": "@quartz-community/plugin-template",
"name": "@quartz-community/og-image",
"version": "0.1.0",
"description": "Template repository for Quartz community plugins.",
"description": "OG image emitter plugin for Quartz. Generates social preview images using satori and sharp.",
"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/og-image"
},
"keywords": [
"quartz",
"quartz-plugin",
"plugin-template",
"remark",
"rehype",
"mdast",
"hast"
"og-image",
"emitter",
"social-image",
"satori"
],
"files": [
"dist",
@ -30,14 +29,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"
},
"./package.json": "./package.json"
},
"main": "./dist/index.js",
@ -67,13 +58,10 @@
},
"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",
"@quartz-community/utils": "github:quartz-community/utils",
"reading-time": "^1.5.0",
"satori": "^0.12.0",
"sharp": "^0.33.0",
"vfile": "^6.0.3"
},
"devDependencies": {

View file

@ -1,33 +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 frontmatter = props.fileData?.frontmatter as { title?: string } | undefined;
const title = frontmatter?.title ?? "Untitled";
const fullText = `${prefix}${title}${suffix}`;
return <div class={classNames(className)}>{fullText}</div>;
};
Component.css = style;
Component.afterDOMLoaded = script;
return Component;
}) satisfies QuartzComponentConstructor;

View file

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

View file

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

View file

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

View file

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

View file

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

571
src/emitter.tsx Normal file
View file

@ -0,0 +1,571 @@
import path from "node:path";
import fs from "node:fs/promises";
import type { Readable } from "node:stream";
import { styleText } from "node:util";
import satori, { type FontWeight, type SatoriOptions } from "satori";
import sharp from "sharp";
import readingTime from "reading-time";
import type {
QuartzEmitterPlugin,
BuildCtx,
FilePath,
FullSlug,
GlobalConfiguration,
} from "@quartz-community/types";
import { joinSegments } from "@quartz-community/types";
import { unescapeHTML } from "@quartz-community/utils";
import type { JSX } from "preact";
import {
type FontSpecification,
type Theme,
type ThemeKey,
getFontSpecificationName,
} from "./theme.js";
import { loadEmoji, getIconCode } from "./emoji.js";
const QUARTZ = "quartz";
type Frontmatter = {
title?: string;
description?: string;
socialDescription?: string;
socialImage?: string;
tags?: string[];
} & Record<string, unknown>;
type QuartzPluginData = {
slug?: FullSlug;
frontmatter?: Frontmatter;
description?: string;
text?: string;
filePath?: string;
dates?: Record<string, Date>;
};
const defaultHeaderWeight = [700];
const defaultBodyWeight = [400];
const write = async (args: {
ctx: BuildCtx;
content: string | Buffer | Readable;
slug: FullSlug;
ext: string;
}): Promise<FilePath> => {
const pathToPage = joinSegments(args.ctx.argv.output, args.slug + args.ext) as FilePath;
const dir = path.dirname(pathToPage);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(pathToPage, args.content);
return pathToPage;
};
function getFileExtension(path: string): string | undefined {
return path.match(/\.([^./?#]+)(?:[?#]|$)/)?.[1];
}
function isAbsoluteURL(url: string): boolean {
return /^https?:\/\//.test(url);
}
function getDate(cfg: GlobalConfiguration, data: Record<string, unknown>): Date | undefined {
return (data.dates as Record<string, Date> | undefined)?.[cfg.defaultDateType ?? "modified"];
}
function formatDate(d: Date, locale: string = "en-US"): string {
return d.toLocaleDateString(locale, {
year: "numeric",
month: "short",
day: "2-digit",
});
}
export async function getSatoriFonts(headerFont: FontSpecification, bodyFont: FontSpecification) {
const headerWeights: FontWeight[] = (
typeof headerFont === "string"
? defaultHeaderWeight
: (headerFont.weights ?? defaultHeaderWeight)
) as FontWeight[];
const bodyWeights: FontWeight[] = (
typeof bodyFont === "string" ? defaultBodyWeight : (bodyFont.weights ?? defaultBodyWeight)
) as FontWeight[];
const headerFontName = typeof headerFont === "string" ? headerFont : headerFont.name;
const bodyFontName = typeof bodyFont === "string" ? bodyFont : bodyFont.name;
const headerFontPromises = headerWeights.map(async (weight) => {
const data = await fetchTtf(headerFontName, weight);
if (!data) return null;
return {
name: headerFontName,
data,
weight,
style: "normal" as const,
};
});
const bodyFontPromises = bodyWeights.map(async (weight) => {
const data = await fetchTtf(bodyFontName, weight);
if (!data) return null;
return {
name: bodyFontName,
data,
weight,
style: "normal" as const,
};
});
const [headerFonts, bodyFonts] = await Promise.all([
Promise.all(headerFontPromises),
Promise.all(bodyFontPromises),
]);
const fonts: SatoriOptions["fonts"] = [
...headerFonts.filter((font): font is NonNullable<typeof font> => font !== null),
...bodyFonts.filter((font): font is NonNullable<typeof font> => font !== null),
];
return fonts;
}
export async function fetchTtf(
rawFontName: string,
weight: FontWeight,
): Promise<Buffer | undefined> {
const fontName = rawFontName.replaceAll(" ", "+");
const cacheKey = `${fontName}-${weight}`;
const cacheDir = path.join(QUARTZ, ".quartz-cache", "fonts");
const cachePath = path.join(cacheDir, cacheKey);
try {
await fs.access(cachePath);
return fs.readFile(cachePath);
} catch {}
const cssResponse = await fetch(
`https://fonts.googleapis.com/css2?family=${fontName}:wght@${weight}`,
);
const css = await cssResponse.text();
const urlRegex = /url\((https:\/\/fonts.gstatic.com\/s\/.*?.ttf)\)/g;
const match = urlRegex.exec(css);
if (!match) {
console.log(
styleText(
"yellow",
`\nWarning: Failed to fetch font ${rawFontName} with weight ${weight}, got ${cssResponse.statusText}`,
),
);
return;
}
const fontUrl = match[1];
if (!fontUrl) {
return;
}
const fontResponse = await fetch(fontUrl);
const fontData = Buffer.from(await fontResponse.arrayBuffer());
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(cachePath, fontData);
return fontData;
}
export type SocialImageOptions = {
colorScheme: ThemeKey;
height: number;
width: number;
excludeRoot: boolean;
defaultTitle?: string;
defaultDescription?: string;
readingTimeText?: (minutes: number) => string;
imageStructure: (
options: ImageOptions & {
userOpts: UserOpts;
iconBase64?: string;
},
) => JSX.Element;
};
export type UserOpts = Omit<SocialImageOptions, "imageStructure">;
export type ImageOptions = {
title: string;
description: string;
fonts: SatoriOptions["fonts"];
cfg: GlobalConfiguration;
fileData: QuartzPluginData;
};
export const defaultImage: SocialImageOptions["imageStructure"] = ({
cfg,
userOpts,
title,
description,
fileData,
iconBase64,
}) => {
const { colorScheme } = userOpts;
const theme = cfg.theme as Theme;
const fontBreakPoint = 32;
const useSmallerFont = title.length > fontBreakPoint;
const rawDate = getDate(cfg, fileData);
const date = rawDate ? formatDate(rawDate, cfg.locale) : null;
const { minutes } = readingTime(fileData.text ?? "");
const readingTimeText = (userOpts.readingTimeText ?? ((time) => `${time} min read`))(
Math.ceil(minutes),
);
const tags = fileData.frontmatter?.tags ?? [];
const bodyFont = getFontSpecificationName(theme.typography.body);
const headerFont = getFontSpecificationName(theme.typography.header);
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
width: "100%",
backgroundColor: theme.colors[colorScheme].light,
padding: "2.5rem",
fontFamily: bodyFont,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "1rem",
marginBottom: "0.5rem",
}}
>
{iconBase64 && (
<img
src={iconBase64}
alt=""
width={56}
height={56}
style={{
borderRadius: "50%",
}}
/>
)}
<div
style={{
display: "flex",
fontSize: 32,
color: theme.colors[colorScheme].gray,
fontFamily: bodyFont,
}}
>
{cfg.baseUrl}
</div>
</div>
<div
style={{
display: "flex",
marginTop: "1rem",
marginBottom: "1.5rem",
}}
>
<h1
style={{
margin: 0,
fontSize: useSmallerFont ? 64 : 72,
fontFamily: headerFont,
fontWeight: 700,
color: theme.colors[colorScheme].dark,
lineHeight: 1.2,
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: 2,
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{title}
</h1>
</div>
<div
style={{
display: "flex",
flex: 1,
fontSize: 36,
color: theme.colors[colorScheme].darkgray,
lineHeight: 1.4,
}}
>
<p
style={{
margin: 0,
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: 5,
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{description}
</p>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: "2rem",
paddingTop: "2rem",
borderTop: `1px solid ${theme.colors[colorScheme].lightgray}`,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "2rem",
color: theme.colors[colorScheme].gray,
fontSize: 28,
}}
>
{date && (
<div style={{ display: "flex", alignItems: "center" }}>
<svg
style={{ marginRight: "0.5rem" }}
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
role="img"
aria-label="Date"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
{date}
</div>
)}
<div style={{ display: "flex", alignItems: "center" }}>
<svg
style={{ marginRight: "0.5rem" }}
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
role="img"
aria-label="Reading time"
>
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
{readingTimeText}
</div>
</div>
<div
style={{
display: "flex",
gap: "0.5rem",
flexWrap: "wrap",
justifyContent: "flex-end",
maxWidth: "60%",
}}
>
{tags.slice(0, 3).map((tag) => (
<div
style={{
display: "flex",
padding: "0.5rem 1rem",
backgroundColor: theme.colors[colorScheme].highlight,
color: theme.colors[colorScheme].secondary,
borderRadius: "10px",
fontSize: 24,
}}
>
#{tag}
</div>
))}
</div>
</div>
</div>
);
};
const defaultOptions: SocialImageOptions = {
colorScheme: "lightMode",
width: 1200,
height: 630,
imageStructure: defaultImage,
excludeRoot: false,
defaultTitle: "Untitled",
defaultDescription: "No description provided",
readingTimeText: (minutes) => `${minutes} min read`,
};
async function generateSocialImage(
{ cfg, description, fonts, title, fileData }: ImageOptions,
userOpts: SocialImageOptions,
): Promise<Readable> {
const { width, height } = userOpts;
const iconPath = joinSegments(QUARTZ, "static", "icon.png");
let iconBase64: string | undefined = undefined;
try {
const iconData = await fs.readFile(iconPath);
iconBase64 = `data:image/png;base64,${iconData.toString("base64")}`;
} catch {
console.warn(styleText("yellow", `Warning: Could not find icon at ${iconPath}`));
}
const imageComponent = userOpts.imageStructure({
cfg,
userOpts,
title,
description,
fonts,
fileData,
iconBase64,
});
const svg = await satori(imageComponent, {
width,
height,
fonts,
loadAdditionalAsset: async (languageCode: string, segment: string) => {
if (languageCode === "emoji") {
return await loadEmoji(getIconCode(segment));
}
return languageCode;
},
});
return sharp(Buffer.from(svg)).webp({ quality: 40 });
}
async function processOgImage(
ctx: BuildCtx,
fileData: QuartzPluginData,
fonts: SatoriOptions["fonts"],
fullOptions: SocialImageOptions,
) {
const cfg = ctx.cfg.configuration;
const slug = fileData.slug!;
const titleSuffix = cfg.pageTitleSuffix ?? "";
const frontmatter = fileData.frontmatter;
const title = (frontmatter?.title ?? fullOptions.defaultTitle ?? "") + titleSuffix;
const description =
frontmatter?.socialDescription ??
frontmatter?.description ??
unescapeHTML(fileData.description?.trim() ?? fullOptions.defaultDescription ?? "");
const stream = await generateSocialImage(
{
title,
description,
fonts,
cfg,
fileData,
},
fullOptions,
);
return write({
ctx,
content: stream,
slug: `${slug}-og-image` as FullSlug,
ext: ".webp",
});
}
export const CustomOgImagesEmitterName = "CustomOgImages";
export const CustomOgImages: QuartzEmitterPlugin<Partial<SocialImageOptions>> = (userOpts) => {
const fullOptions = { ...defaultOptions, ...userOpts };
return {
name: CustomOgImagesEmitterName,
getQuartzComponents() {
return [];
},
async *emit(ctx, content, _resources) {
const cfg = ctx.cfg.configuration;
const theme = cfg.theme as Theme;
const headerFont = theme.typography.header;
const bodyFont = theme.typography.body;
const fonts = await getSatoriFonts(headerFont, bodyFont);
for (const [_tree, vfile] of content) {
const data = vfile.data as QuartzPluginData;
if (data.frontmatter?.socialImage !== undefined) continue;
yield processOgImage(ctx, data, fonts, fullOptions);
}
},
async *partialEmit(ctx, _content, _resources, changeEvents) {
const cfg = ctx.cfg.configuration;
const theme = cfg.theme as Theme;
const headerFont = theme.typography.header;
const bodyFont = theme.typography.body;
const fonts = await getSatoriFonts(headerFont, bodyFont);
for (const changeEvent of changeEvents) {
if (!changeEvent.file) continue;
const data = changeEvent.file.data as QuartzPluginData;
if (data.frontmatter?.socialImage !== undefined) continue;
if (changeEvent.type === "add" || changeEvent.type === "change") {
yield processOgImage(ctx, data, fonts, fullOptions);
}
}
},
externalResources: (ctx) => {
if (!ctx.cfg.configuration.baseUrl) {
return {};
}
const baseUrl = ctx.cfg.configuration.baseUrl;
return {
additionalHead: [
(pageData: QuartzPluginData) => {
const isRealFile = pageData.filePath !== undefined;
let userDefinedOgImagePath = pageData.frontmatter?.socialImage;
if (userDefinedOgImagePath) {
userDefinedOgImagePath = isAbsoluteURL(userDefinedOgImagePath)
? userDefinedOgImagePath
: `https://${baseUrl}/static/${userDefinedOgImagePath}`;
}
const generatedOgImagePath = isRealFile
? `https://${baseUrl}/${pageData.slug!}-og-image.webp`
: undefined;
const defaultOgImagePath = `https://${baseUrl}/static/og-image.png`;
const ogImagePath =
userDefinedOgImagePath ?? generatedOgImagePath ?? defaultOgImagePath;
const ogImageMimeType = `image/${getFileExtension(ogImagePath) ?? "png"}`;
return (
<>
{!userDefinedOgImagePath && (
<>
<meta property="og:image:width" content={fullOptions.width.toString()} />
<meta property="og:image:height" content={fullOptions.height.toString()} />
</>
)}
<meta property="og:image" content={ogImagePath} />
<meta property="og:image:url" content={ogImagePath} />
<meta name="twitter:image" content={ogImagePath} />
<meta property="og:image:type" content={ogImageMimeType} />
</>
);
},
],
};
},
};
};

50
src/emoji.ts Normal file
View file

@ -0,0 +1,50 @@
const U200D = String.fromCharCode(8205);
const UFE0Fg = /\uFE0F/g;
export function getIconCode(char: string) {
return toCodePoint(char.indexOf(U200D) < 0 ? char.replace(UFE0Fg, "") : char);
}
function toCodePoint(unicodeSurrogates: string) {
const r: string[] = [];
let c = 0,
p = 0,
i = 0;
while (i < unicodeSurrogates.length) {
c = unicodeSurrogates.charCodeAt(i++);
if (p) {
r.push((65536 + ((p - 55296) << 10) + (c - 56320)).toString(16));
p = 0;
} else if (55296 <= c && c <= 56319) {
p = c;
} else {
r.push(c.toString(16));
}
}
return r.join("-");
}
type EmojiMap = {
codePointToName: Record<string, string>;
nameToBase64: Record<string, string>;
};
let emojimap: EmojiMap | undefined = undefined;
export async function loadEmoji(code: string) {
if (!emojimap) {
const path = await import("node:path");
const fs = await import("node:fs/promises");
const mapPath = path.join("quartz", "util", "emojimap.json");
const data = JSON.parse(await fs.readFile(mapPath, "utf-8"));
emojimap = data;
}
const name = emojimap!.codePointToName[`${code.toUpperCase()}`];
if (!name) throw new Error(`codepoint ${code} not found in map`);
const b64 = emojimap!.nameToBase64[name];
if (!b64) throw new Error(`name ${name} not found in map`);
return b64;
}

View file

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

View file

@ -1,9 +0,0 @@
import enUS from "./locales/en-US";
const locales: Record<string, typeof enUS> = {
"en-US": enUS,
};
export function i18n(locale: string) {
return locales[locale] || enUS;
}

View file

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

View file

@ -1,28 +1,3 @@
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";
// Re-export shared types from @quartz-community/types
export type {
QuartzComponent,
QuartzComponentProps,
QuartzComponentConstructor,
StringResource,
QuartzTransformerPlugin,
QuartzFilterPlugin,
QuartzEmitterPlugin,
QuartzPageTypePlugin,
QuartzPageTypePluginInstance,
PageMatcher,
PageGenerator,
VirtualPage,
} from "@quartz-community/types";
export { CustomOgImages, CustomOgImagesEmitterName } from "./emitter.js";
export type { SocialImageOptions, ImageOptions, UserOpts } from "./emitter.js";
export type { QuartzEmitterPlugin } from "@quartz-community/types";

45
src/theme.ts Normal file
View file

@ -0,0 +1,45 @@
export interface ColorScheme {
light: string;
lightgray: string;
gray: string;
darkgray: string;
dark: string;
secondary: string;
tertiary: string;
highlight: string;
textHighlight: string;
}
interface Colors {
lightMode: ColorScheme;
darkMode: ColorScheme;
}
export type FontSpecification =
| string
| {
name: string;
weights?: number[];
includeItalic?: boolean;
};
export interface Theme {
typography: {
title?: FontSpecification;
header: FontSpecification;
body: FontSpecification;
code: FontSpecification;
};
cdnCaching: boolean;
colors: Colors;
fontOrigin: "googleFonts" | "local";
}
export type ThemeKey = keyof Colors;
export function getFontSpecificationName(spec: FontSpecification): string {
if (typeof spec === "string") {
return spec;
}
return spec.name;
}

View file

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

View file

@ -1,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,41 +0,0 @@
import type {
BuildCtx,
QuartzConfig,
ProcessedContent,
QuartzPluginData,
} from "@quartz-community/types";
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];
};

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

@ -16,7 +16,7 @@
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["vitest/globals", "node"],
"types": ["node"],
"verbatimModuleSyntax": true,
"jsx": "react-jsx",
"jsxImportSource": "preact"

View file

@ -3,8 +3,6 @@ import { defineConfig } from "tsup";
export default defineConfig({
entry: {
index: "src/index.ts",
types: "src/types.ts",
"components/index": "src/components/index.ts",
},
format: ["esm"],
dts: true,
@ -14,32 +12,9 @@ export default defineConfig({
target: "es2022",
splitting: false,
outDir: "dist",
esbuildOptions(options) {
external: ["sharp"],
esbuildOptions(options: any) {
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",
};
});
},
},
],
});