feat(marketing): add dynamic OG image for changelog pages#1209
Conversation
Generate branded OpenGraph images for changelog entries with the post title, date, and cover image as background, matching the blog OG pattern.
📝 WalkthroughWalkthroughAdds a Next.js Open Graph image generator for individual changelog entries that dynamically renders OG images based on slug, featuring dark backgrounds, optional cover images, and gradient overlays. Removes manual image metadata from the changelog page template since the dedicated OG generator now handles image generation automatically. Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
🧹 Preview Cleanup CompleteThe following preview resources have been cleaned up:
Thank you for your contribution! 🎉 |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@apps/marketing/src/app/changelog/`[slug]/opengraph-image.tsx:
- Around line 11-24: The readFileAsDataUri function fails when filePath begins
with "/" because path.join(process.cwd(), "public", filePath) will ignore
"public"; normalize filePath first (e.g., strip a leading slash: const
normalized = filePath.startsWith("/") ? filePath.slice(1) : filePath) and use
that in path.join to build the absolutePath, and in the catch block do not
swallow the error: add a prefixed console.error like
console.error("[opengraph-image/readFileAsDataUri] Error reading", filePath,
err) before returning null (or rethrow if you prefer) so failures are logged
with context.
- Around line 70-74: Derive the cover MIME from the file extension instead of
hardcoding "image/png": inspect entry.image with path.extname (or similar) and
map extensions like .png/.jpg/.jpeg/.gif/.webp to the correct MIME, then pass
that MIME into readFileAsDataUri (the call that currently uses "image/png");
ensure you handle unknown/uppercase extensions by normalizing and fallback to a
sensible default (e.g., "application/octet-stream" or "image/png") and update
the coverImageUri assignment accordingly.
🧹 Nitpick comments (1)
apps/marketing/src/app/changelog/[slug]/opengraph-image.tsx (1)
48-129: Extract repeated style tokens to top‑level constantsThere are several repeated colors, sizes, and the gradient string. Centralizing these makes future tweaks safer and avoids inline magic values.
♻️ Example extraction
+const COLOR_BG = "#0a0a0a"; +const COLOR_TEXT = "#ffffff"; +const COLOR_MUTED = "#999999"; +const GRADIENT_OVERLAY = + "linear-gradient(to bottom, rgba(10,10,10,0.65) 0%, rgba(10,10,10,0.25) 50%, rgba(10,10,10,0.7) 100%)"; +const CONTENT_PADDING = "48px 64px"; + ... style={{ - background: "#0a0a0a", + background: COLOR_BG, width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center", - color: "#ffffff", + color: COLOR_TEXT, fontSize: 48, fontFamily: "Inter", }} ... background: - "linear-gradient(to bottom, rgba(10,10,10,0.65) 0%, rgba(10,10,10,0.25) 50%, rgba(10,10,10,0.7) 100%)", + GRADIENT_OVERLAY, }} /> ... padding: "48px 64px",As per coding guidelines, Extract hardcoded magic numbers, strings, and enums to named constants at module top instead of leaving them inline in logic.
| function readFileAsDataUri({ | ||
| filePath, | ||
| mime, | ||
| }: { | ||
| filePath: string; | ||
| mime: string; | ||
| }): string | null { | ||
| try { | ||
| const absolutePath = path.join(process.cwd(), "public", filePath); | ||
| const buffer = fs.readFileSync(absolutePath); | ||
| return `data:${mime};base64,${buffer.toString("base64")}`; | ||
| } catch { | ||
| return null; | ||
| } |
There was a problem hiding this comment.
Normalize public asset paths and log unexpected read failures
If filePath starts with / (common for public assets), path.join() discards the public prefix and the read fails. Also, the catch currently swallows all errors, making failures hard to diagnose.
🛠️ Suggested fix
function readFileAsDataUri({
filePath,
mime,
}: {
filePath: string;
mime: string;
}): string | null {
try {
- const absolutePath = path.join(process.cwd(), "public", filePath);
+ const relativePath = filePath.replace(/^\/+/, "");
+ const absolutePath = path.join(process.cwd(), "public", relativePath);
const buffer = fs.readFileSync(absolutePath);
return `data:${mime};base64,${buffer.toString("base64")}`;
- } catch {
+ } catch (err) {
+ const error = err as NodeJS.ErrnoException;
+ if (error.code !== "ENOENT") {
+ console.error("[og/readFileAsDataUri] failed to read asset", {
+ filePath,
+ error,
+ });
+ }
return null;
}
}As per coding guidelines, Never swallow errors silently; at minimum log errors with context before rethrowing or handling them explicitly; and Use prefixed console logging with consistent context pattern: [domain/operation] message for entry/exit of significant operations, external API calls, and error conditions.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function readFileAsDataUri({ | |
| filePath, | |
| mime, | |
| }: { | |
| filePath: string; | |
| mime: string; | |
| }): string | null { | |
| try { | |
| const absolutePath = path.join(process.cwd(), "public", filePath); | |
| const buffer = fs.readFileSync(absolutePath); | |
| return `data:${mime};base64,${buffer.toString("base64")}`; | |
| } catch { | |
| return null; | |
| } | |
| function readFileAsDataUri({ | |
| filePath, | |
| mime, | |
| }: { | |
| filePath: string; | |
| mime: string; | |
| }): string | null { | |
| try { | |
| const relativePath = filePath.replace(/^\/+/, ""); | |
| const absolutePath = path.join(process.cwd(), "public", relativePath); | |
| const buffer = fs.readFileSync(absolutePath); | |
| return `data:${mime};base64,${buffer.toString("base64")}`; | |
| } catch (err) { | |
| const error = err as NodeJS.ErrnoException; | |
| if (error.code !== "ENOENT") { | |
| console.error("[og/readFileAsDataUri] failed to read asset", { | |
| filePath, | |
| error, | |
| }); | |
| } | |
| return null; | |
| } | |
| } |
🤖 Prompt for AI Agents
In `@apps/marketing/src/app/changelog/`[slug]/opengraph-image.tsx around lines 11
- 24, The readFileAsDataUri function fails when filePath begins with "/" because
path.join(process.cwd(), "public", filePath) will ignore "public"; normalize
filePath first (e.g., strip a leading slash: const normalized =
filePath.startsWith("/") ? filePath.slice(1) : filePath) and use that in
path.join to build the absolutePath, and in the catch block do not swallow the
error: add a prefixed console.error like
console.error("[opengraph-image/readFileAsDataUri] Error reading", filePath,
err) before returning null (or rethrow if you prefer) so failures are logged
with context.
| const coverImageUri = entry.image | ||
| ? readFileAsDataUri({ | ||
| filePath: entry.image, | ||
| mime: "image/png", | ||
| }) |
There was a problem hiding this comment.
Derive the cover MIME type from the file extension
entry.image isn’t guaranteed to be PNG. Hardcoding image/png can cause non‑PNG covers to render incorrectly. Consider a small lookup map based on path.extname().
✅ Example
+const MIME_BY_EXT: Record<string, string> = {
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".webp": "image/webp",
+ ".svg": "image/svg+xml",
+};
+
const coverImageUri = entry.image
? readFileAsDataUri({
filePath: entry.image,
- mime: "image/png",
+ mime: MIME_BY_EXT[path.extname(entry.image).toLowerCase()] ?? "image/png",
})
: null;🤖 Prompt for AI Agents
In `@apps/marketing/src/app/changelog/`[slug]/opengraph-image.tsx around lines 70
- 74, Derive the cover MIME from the file extension instead of hardcoding
"image/png": inspect entry.image with path.extname (or similar) and map
extensions like .png/.jpg/.jpeg/.gif/.webp to the correct MIME, then pass that
MIME into readFileAsDataUri (the call that currently uses "image/png"); ensure
you handle unknown/uppercase extensions by normalizing and fallback to a
sensible default (e.g., "application/octet-stream" or "image/png") and update
the coverImageUri assignment accordingly.
Summary
opengraph-image.tsxforchangelog/[slug]that dynamically generates branded 1200x630 OG imagesimagesoverrides from changelog metadata (Next.js auto-discovers the route)Test plan
/changelog/2026-02-02-file-explorer-workspace-improvementsand inspect the OG image via dev tools or opengraph.xyzSummary by CodeRabbit