Skip to content

Commit 19195db

Browse files
committed
perf(quoteforge[cli]): cache templates, pool pages, polish themes list
1 parent 88be1e9 commit 19195db

6 files changed

Lines changed: 137 additions & 32 deletions

File tree

src/cli/commands/slides.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const slidesCommand = new Command("slides")
1616
.option("--no-zip", "Skip ZIP creation")
1717
.option("--no-counter", "Disable counter overlay for all slides")
1818
.option("--concurrency <n>", "Parallel render workers", "4")
19+
.option("--zip-level <n>", "ZIP compression level 0-9 (0=store, 6=default, 9=max)", "6")
1920
.option("--scale <n>", "Pixel ratio", "2")
2021
.option("--open", "Open output folder after generation")
2122
.action(async (file: string, opts: {
@@ -26,6 +27,7 @@ export const slidesCommand = new Command("slides")
2627
zip: boolean;
2728
counter: boolean;
2829
concurrency: string;
30+
zipLevel: string;
2931
scale: string;
3032
open?: boolean;
3133
}) => {
@@ -106,7 +108,8 @@ export const slidesCommand = new Command("slides")
106108
}
107109

108110
if (opts.zip && slideIndex === undefined && buffers.length > 1) {
109-
const zipBuf = await buildZip(buffers, names);
111+
const zipLevel = parseInt(opts.zipLevel, 10);
112+
const zipBuf = await buildZip(buffers, names, zipLevel);
110113
const zipPath = resolve("outputs", `${deckName}.zip`);
111114
writeFileSync(zipPath, zipBuf);
112115
console.log(chalk.green(` ✓ ZIP:`), chalk.dim(zipPath));

src/cli/commands/themes.ts

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,43 @@ const listCmd = new Command("list")
2020
console.log(chalk.dim("No themes found."));
2121
return;
2222
}
23-
console.log(chalk.bold("\nAvailable themes:\n"));
24-
for (const { file, source, theme } of themes) {
25-
const bg = chalk.hex(theme.colors.background)("██");
26-
const accent = chalk.hex(theme.colors.accent)("██");
27-
const headline = chalk.hex(theme.colors.headline)("██");
28-
const tag = source === "user" ? chalk.cyan(" [user]") : source === "repo" ? chalk.dim(" [repo]") : "";
29-
console.log(
30-
` ${bg} ${accent} ${headline} ${chalk.bold(theme.displayName)} ${chalk.dim(`(${basename(file, ".json")})`)}${tag}`,
31-
);
32-
console.log(
33-
` ${chalk.dim(`${theme.typography["font-headline"]} / ${theme.typography["font-body"]}`)}`,
34-
);
23+
24+
const rows = themes.map(({ file, source, theme }) => ({
25+
slug: basename(file, ".json"),
26+
name: theme.displayName,
27+
source,
28+
colors: [
29+
theme.colors.background,
30+
theme.colors.headline,
31+
theme.colors.accent,
32+
theme.colors.body,
33+
theme.colors.label,
34+
],
35+
fonts: `${theme.typography["font-headline"]} · ${theme.typography["font-body"]}`,
36+
}));
37+
38+
const nameWidth = Math.max(...rows.map((r) => r.name.length));
39+
const slugWidth = Math.max(...rows.map((r) => r.slug.length));
40+
const userCount = rows.filter((r) => r.source === "user").length;
41+
42+
console.log();
43+
console.log(
44+
chalk.bold(` Themes`) +
45+
chalk.dim(` ${rows.length} total${userCount ? ` · ${userCount} user` : ""}`),
46+
);
47+
console.log(chalk.dim(" ────────────────────────────────────────────────────────────"));
48+
49+
for (const row of rows) {
50+
const swatch = row.colors.map((c) => chalk.hex(c)("██")).join(" ");
51+
const name = chalk.bold(row.name.padEnd(nameWidth));
52+
const slug = chalk.dim(row.slug.padEnd(slugWidth));
53+
const fonts = chalk.dim(row.fonts);
54+
const tag = row.source === "user" ? chalk.cyan(" ●") : "";
55+
console.log(` ${swatch} ${name} ${slug} ${fonts}${tag}`);
56+
}
57+
58+
if (userCount > 0) {
59+
console.log(chalk.dim(`\n ${chalk.cyan("●")} user theme`));
3560
}
3661
console.log();
3762
});

src/cli/utils/zip.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Writable } from "node:stream";
44
export async function buildZip(
55
buffers: Buffer[],
66
names: string[],
7+
level = 6,
78
): Promise<Buffer> {
89
return new Promise((resolve, reject) => {
910
const chunks: Buffer[] = [];
@@ -15,7 +16,8 @@ export async function buildZip(
1516
},
1617
});
1718

18-
const archive = archiver("zip", { zlib: { level: 9 } });
19+
const clampedLevel = Math.max(0, Math.min(9, level));
20+
const archive = archiver("zip", { zlib: { level: clampedLevel } });
1921

2022
archive.on("error", reject);
2123
writable.on("finish", () => resolve(Buffer.concat(chunks)));

src/renderer/renderer.ts

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import puppeteer from "puppeteer-core";
2-
import type { Browser } from "puppeteer-core";
2+
import type { Browser, Page } from "puppeteer-core";
33
import { renderTemplate } from "./template-engine.js";
44
import type { RenderMeta } from "./template-engine.js";
55
import type { CardContent, Theme, SizeName } from "../cli/utils/validator.js";
@@ -11,31 +11,45 @@ async function launch(): Promise<Browser> {
1111
return puppeteer.launch({ headless: true, executablePath });
1212
}
1313

14-
export async function renderCard(
14+
export async function renderCardOnPage(
15+
page: Page,
1516
content: CardContent,
1617
theme: Theme,
1718
size: SizeName,
1819
scale = 2,
1920
meta?: Partial<RenderMeta>,
20-
browser?: Browser,
2121
): Promise<Buffer> {
2222
const dimensions = SIZES[size];
2323
const html = renderTemplate(content, theme, dimensions, meta);
2424

25+
await page.setViewport({
26+
width: dimensions.w,
27+
height: dimensions.h,
28+
deviceScaleFactor: scale,
29+
});
30+
await page.setContent(html, { waitUntil: "networkidle2", timeout: 30_000 });
31+
await page.waitForFunction(() => document.fonts.ready.then(() => true), { timeout: 15_000 });
32+
const screenshot = await page.screenshot({ type: "png" });
33+
return Buffer.from(screenshot);
34+
}
35+
36+
export async function renderCard(
37+
content: CardContent,
38+
theme: Theme,
39+
size: SizeName,
40+
scale = 2,
41+
meta?: Partial<RenderMeta>,
42+
browser?: Browser,
43+
): Promise<Buffer> {
2544
const ownBrowser = !browser;
2645
const b = browser ?? await launch();
2746
try {
2847
const page = await b.newPage();
29-
await page.setViewport({
30-
width: dimensions.w,
31-
height: dimensions.h,
32-
deviceScaleFactor: scale,
33-
});
34-
await page.setContent(html, { waitUntil: "networkidle2", timeout: 30_000 });
35-
await page.waitForFunction(() => document.fonts.ready.then(() => true), { timeout: 15_000 });
36-
const screenshot = await page.screenshot({ type: "png" });
37-
await page.close();
38-
return Buffer.from(screenshot);
48+
try {
49+
return await renderCardOnPage(page, content, theme, size, scale, meta);
50+
} finally {
51+
await page.close();
52+
}
3953
} finally {
4054
if (ownBrowser) {
4155
await b.close();

src/renderer/slide-renderer.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { readFileSync } from "node:fs";
22
import { resolve } from "node:path";
3-
import { renderCard, launchBrowser } from "./renderer.js";
3+
import type { Browser, Page } from "puppeteer-core";
4+
import { renderCardOnPage, launchBrowser } from "./renderer.js";
45
import type { RenderMeta } from "./template-engine.js";
56
import type { DeckContent, Theme, SizeName, CardContent } from "../cli/utils/validator.js";
67
import { ThemeSchema } from "../cli/utils/validator.js";
@@ -22,6 +23,46 @@ function loadTheme(name: string): Theme {
2223
return ThemeSchema.parse(JSON.parse(raw));
2324
}
2425

26+
interface PagePool {
27+
acquire(): Promise<Page>;
28+
release(page: Page): void;
29+
drain(): Promise<void>;
30+
}
31+
32+
function createPagePool(browser: Browser, size: number): PagePool {
33+
const idle: Page[] = [];
34+
const allPages: Page[] = [];
35+
const waiters: ((page: Page) => void)[] = [];
36+
let created = 0;
37+
38+
async function acquire(): Promise<Page> {
39+
const reused = idle.pop();
40+
if (reused) return reused;
41+
if (created < size) {
42+
created++;
43+
const page = await browser.newPage();
44+
allPages.push(page);
45+
return page;
46+
}
47+
return new Promise<Page>((resolve) => waiters.push(resolve));
48+
}
49+
50+
function release(page: Page): void {
51+
const waiter = waiters.shift();
52+
if (waiter) {
53+
waiter(page);
54+
return;
55+
}
56+
idle.push(page);
57+
}
58+
59+
async function drain(): Promise<void> {
60+
await Promise.all(allPages.map((p) => p.close().catch(() => {})));
61+
}
62+
63+
return { acquire, release, drain };
64+
}
65+
2566
async function semaphore<T>(
2667
tasks: (() => Promise<T>)[],
2768
limit: number,
@@ -66,6 +107,8 @@ export async function renderDeck(
66107
: deck.slides.map((slide, i) => ({ slide, originalIndex: i }));
67108

68109
const browser = await launchBrowser();
110+
const poolSize = Math.min(concurrency, slidesToRender.length);
111+
const pool = createPagePool(browser, poolSize);
69112
try {
70113
const tasks = slidesToRender.map(({ slide, originalIndex }) => async () => {
71114
if (!slide) throw new Error(`Slide index ${originalIndex} out of range`);
@@ -98,7 +141,13 @@ export async function renderDeck(
98141
counter,
99142
};
100143

101-
const buffer = await renderCard(cardContent, theme, sizeName, scale, meta, browser);
144+
const page = await pool.acquire();
145+
let buffer: Buffer;
146+
try {
147+
buffer = await renderCardOnPage(page, cardContent, theme, sizeName, scale, meta);
148+
} finally {
149+
pool.release(page);
150+
}
102151
const paddedIndex = String(originalIndex + 1).padStart(padWidth, "0");
103152
const name = `${deckName}-${paddedIndex}.png`;
104153

@@ -112,6 +161,7 @@ export async function renderDeck(
112161
names: results.map((r) => r.name),
113162
};
114163
} finally {
164+
await pool.drain();
115165
await browser.close();
116166
}
117167
}

src/renderer/template-engine.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,23 @@ import { templatesDir } from "../assetBundle.js";
66
import type { CardContent, Theme, Part, PartStyle } from "../cli/utils/validator.js";
77

88
const TEMPLATES_DIR = templatesDir();
9+
const IS_DEV = process.env.NODE_ENV === "development";
910

1011
const env = new nunjucks.Environment(
11-
new nunjucks.FileSystemLoader(TEMPLATES_DIR, { noCache: true }),
12+
new nunjucks.FileSystemLoader(TEMPLATES_DIR, { noCache: IS_DEV }),
1213
{ autoescape: true },
1314
);
1415

16+
const cssCache = new Map<string, string>();
17+
function readCssCached(path: string): string {
18+
if (IS_DEV) return readFileSync(path, "utf-8");
19+
const hit = cssCache.get(path);
20+
if (hit !== undefined) return hit;
21+
const value = readFileSync(path, "utf-8");
22+
cssCache.set(path, value);
23+
return value;
24+
}
25+
1526
export interface RenderMeta {
1627
slideIndex: number;
1728
slideTotal: number;
@@ -100,8 +111,8 @@ export function renderTemplate(
100111
const templatePath = `${content.template}/template.njk`;
101112
const basePath = join(TEMPLATES_DIR, "_base.css");
102113
const stylePath = join(TEMPLATES_DIR, content.template, "style.css");
103-
const baseCSS = readFileSync(basePath, "utf-8");
104-
const styleCSS = readFileSync(stylePath, "utf-8");
114+
const baseCSS = readCssCached(basePath);
115+
const styleCSS = readCssCached(stylePath);
105116

106117
return env.render(templatePath, {
107118
card: content,

0 commit comments

Comments
 (0)