Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

og image generator #23

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,5 @@ generated-assets
docs/public
docs/.wrangler
start/.wrangler
start/public
start/public
docs/static/images/og/**/*
1 change: 1 addition & 0 deletions docs/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
static/images/og/**/*
8 changes: 7 additions & 1 deletion docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,26 @@
"dev": "pnpm \"/dev:/\"",
"dev:content": "velite dev --watch",
"dev:svelte": "vite dev",
"build": "velite && node ./scripts/update-velite-output.js && pnpm build:search && vite build",
"build": "velite && node ./scripts/update-velite-output.js && pnpm build:search && pnpm build:og && vite build",
"build:og": "node ./scripts/build-og-images.js",
"build:search": "node ./scripts/build-search-data.js",
"preview": "vite preview",
"check": "velite && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "pnpm build:content && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@resvg/resvg-js": "2.5.0",
"@svecodocs/kit": "workspace:*",
"@sveltejs/adapter-cloudflare": "^4.8.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@tailwindcss/vite": "4.0.0-beta.5",
"mdsx": "^0.0.6",
"phosphor-svelte": "^3.0.0",
"puppeteer": "^23.10.1",
"satori": "^0.12.0",
"satori-html": "^0.3.2",
"sharp": "^0.33.5",
"svelte": "^5.2.11",
"svelte-check": "^4.0.0",
"svelte-preprocess": "^6.0.3",
Expand Down
135 changes: 135 additions & 0 deletions docs/scripts/build-og-images.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { spawn } from "node:child_process";
import puppeteer from "puppeteer";
import { docs } from "../.velite/index.js";
import { mkdir, unlink, stat } from "node:fs/promises";
import { dirname } from "node:path";
import sharp from "sharp";

async function ensureDirectoryExists(filePath) {
const directory = dirname(filePath);
await mkdir(directory, { recursive: true });
}

async function logCompressionStats(tempPath, finalPath) {
const originalSize = (await stat(tempPath)).size;
const compressedSize = (await stat(finalPath)).size;
const savingsPercent = (((originalSize - compressedSize) / originalSize) * 100).toFixed(1);

console.log(
` Compressed: ${(originalSize / 1024 / 1024).toFixed(2)}MB → ${(compressedSize / 1024 / 1024).toFixed(2)}MB (${savingsPercent}% reduction)`
);
}

async function saveAndCompressScreenshot(page, doc) {
const tempPath = `./static/images/og/tmp/${doc.slug}.png`;
const finalPath = `./static/images/og/${doc.slug}.webp`;
await ensureDirectoryExists(tempPath);
await ensureDirectoryExists(finalPath);

await page.screenshot({
path: tempPath,
clip: {
x: 0,
y: 0,
width: 1200,
height: 630,
},
});

await sharp(tempPath)
.webp({
quality: 80,
compressionLevel: 9,
palette: true,
})
.toFile(finalPath);

await logCompressionStats(tempPath, finalPath);

await unlink(tempPath);
}
async function startDevServer() {
const server = spawn("pnpm", ["dev"], {
stdio: ["ignore", "pipe", "inherit"],
detached: false,
});

return new Promise((resolve, reject) => {
server.stdout.on("data", (data) => {
const output = data.toString();
if (output.includes("Local:")) {
resolve(server);
}
});

server.on("error", reject);

setTimeout(() => {
reject(new Error("Dev server failed to start within timeout"));
}, 30000);
});
}

async function build() {
let server;
try {
console.log("Starting dev server for OG image generation...");
server = await startDevServer();
console.log("Server started, waiting for warm-up...");
await new Promise((resolve) => setTimeout(resolve, 3000));
await generateOGImages(docs);
console.log("OG images generated, starting production build...");
} catch (error) {
console.error("Build failed:", error);
process.exit(1);
} finally {
if (server) {
server.kill();
await new Promise((resolve) => {
server.on("close", resolve);
});
}
}
}

async function generateSingleOGImage(doc) {
const browser = await puppeteer.launch({
defaultViewport: {
width: 1440,
height: 900,
deviceScaleFactor: 2,
},
});

try {
const page = await browser.newPage();
const pageUrl = `http://localhost:5173/api/og?title=${doc.title}&description=${
doc.description
}`;

await page.goto(pageUrl, { waitUntil: "networkidle2" });
await saveAndCompressScreenshot(page, doc);
console.log(`✓ Generated: ${doc.title}`);
} finally {
await browser.close();
}
}
async function generateOGImages(docs) {
console.log("☀️ Starting parallel OG image generation...");
const startTime = Date.now();

try {
await Promise.all(docs.map(generateSingleOGImage));

const endTime = Date.now();
console.log(`✅ All images generated in ${(endTime - startTime) / 1000}s!`);
} catch (error) {
console.error("Failed to generate OG images:", error);
throw error;
}
}

build().catch((error) => {
console.error("Unexpected error:", error);
process.exit(1);
});
23 changes: 23 additions & 0 deletions docs/src/lib/components/og.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<script lang="ts">
import LogoLight from "$lib/components/logos/svecosystem-light.svelte";

let { title, description }: { title: string; description: string } = $props();
</script>

<div
class="relative flex h-[630px] w-[1200px] flex-col justify-center bg-[url(/orange-og-bg.png)] bg-center bg-no-repeat"
>
<div class="absolute left-0 top-0 p-8">
<LogoLight class="h-8 w-auto" />
</div>
<div class="absolute bottom-0 right-0 p-8">
<LogoLight class="h-8 w-auto" />
</div>

<div class="p-8">
<h1 class="text-[90px] font-semibold tracking-[-0.15rem]">{title}</h1>
<p class="text-[36px] text-gray-700">
{description}
</p>
</div>
</div>
8 changes: 8 additions & 0 deletions docs/src/routes/(docs)/docs/[...slug]/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
<script lang="ts">
import { DocPage } from "@svecodocs/kit";
import { page } from "$app/stores";
let { data } = $props();

const origin = $derived($page.url.origin);
const ogImageUrl = $derived(`${origin}/images/og/${data.metadata.slug}.png`);
</script>

<svelte:head>
<meta name="twitter:image" content={ogImageUrl} />
<meta property="og:image" content={ogImageUrl} />
</svelte:head>
<DocPage component={data.component} {...data.metadata} />
12 changes: 12 additions & 0 deletions docs/src/routes/api/og/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<script lang="ts">
import OG from "$lib/components/og.svelte";
import { page } from "$app/stores";

const title = $derived($page.url.searchParams.get("title") ?? "Svecodocs");
const description = $derived(
$page.url.searchParams.get("description") ??
"Documentation toolkit for Svecosystem projects."
);
</script>

<OG {title} {description} />
Loading
Loading