diff --git a/src/pages/og/post/[slug].png.ts b/src/pages/og/post/[slug].png.ts
index 18cf8e9..c70c35c 100644
--- a/src/pages/og/post/[slug].png.ts
+++ b/src/pages/og/post/[slug].png.ts
@@ -1,5 +1,5 @@
import type { APIRoute } from 'astro';
-import { getCollection, getEntries } from 'astro:content';
+import { getEntries } from 'astro:content';
import type { CollectionEntry } from 'astro:content';
import { readFileSync } from 'fs';
import { join } from 'path';
@@ -7,12 +7,17 @@ import { generateOgPng } from '@/lib/og';
import { pngResponseWithFallback } from '@/lib/og-response';
import { topicName } from '@/lib/data';
+// Per-post OG cards (title/cover composited into a 1200×630 card) are preserved
+// here as an opt-in feature but NOT generated — one render + one file per post
+// doesn't scale (Cloudflare Pages caps a deploy at 20k files) and adds build
+// time that delays the PR preview link. Posts share the raw cover or the single
+// prebuilt /og-default.png instead (see src/pages/blog/[slug].astro).
+//
+// To re-enable: restore the getCollection import and return the mapped posts,
+// then repoint ogImage in blog/[slug].astro back to `/og/post/${post.id}.png`.
+// Best paired with edge/on-demand caching so cards render once, not per build.
export async function getStaticPaths() {
- const posts = await getCollection('posts', ({ data }) => !data.draft);
- return posts.map((post) => ({
- params: { slug: post.id },
- props: { post },
- }));
+ return [] as { params: { slug: string }; props: { post: CollectionEntry<'posts'> } }[];
}
const MIME: Record = {
From 340b8c09bd5e53a393bb60d77dfcdd1be2fbc758 Mon Sep 17 00:00:00 2001
From: Dinesh <13635627+HumbleBee14@users.noreply.github.com>
Date: Tue, 14 Jul 2026 05:06:17 -0700
Subject: [PATCH 2/8] Add opt-in 'Designed share card' checkbox (env-gated)
- New ogCard frontmatter flag: when set (with a cover), the post renders a
generated 1200x630 card (title over cover); otherwise raw cover / shared card
- Per-post OG generation now runs ONLY for opted-in posts, so build stays flat
- Checkbox shows under a cover in /write, gated by PUBLIC_OG_CARD_OPTIN (off by
default) so it can be toggled centrally; short label + hover info tooltip
- Round-trips via the .write-source.json sidecar like other meta
---
src/content/config.ts | 4 ++++
src/lib/site.ts | 4 ++++
src/pages/blog/[slug].astro | 15 +++++++++++----
src/pages/og/post/[slug].png.ts | 24 +++++++++++++-----------
src/write/WritePortal.tsx | 1 +
src/write/editor/editor-theme.css | 20 +++++++++++++++++++-
src/write/meta/MetaForm.tsx | 18 ++++++++++++++++++
src/write/serialize/toMdx.ts | 4 ++++
8 files changed, 74 insertions(+), 16 deletions(-)
diff --git a/src/content/config.ts b/src/content/config.ts
index 49d9e86..7f148e9 100644
--- a/src/content/config.ts
+++ b/src/content/config.ts
@@ -38,6 +38,10 @@ const posts = defineCollection({
tags: z.array(z.string()).optional(),
updated: z.coerce.date().optional(),
cover: z.union([image(), z.string().url()]).optional(),
+ // Opt in to a generated 1200×630 share card (post title over the cover).
+ // Only these posts render an OG card at build; everyone else uses the raw
+ // cover or the shared default, keeping build time flat.
+ ogCard: z.boolean().optional().default(false),
featured: z.boolean().optional().default(false),
draft: z.boolean().optional().default(false),
}),
diff --git a/src/lib/site.ts b/src/lib/site.ts
index b97f12d..529a408 100644
--- a/src/lib/site.ts
+++ b/src/lib/site.ts
@@ -36,6 +36,10 @@ export const SITE = {
// Shows the "Post to GitHub" button in /write. Flip to true once the GitHub App
// credentials are set in the Cloudflare Function env (see /api/create-pr).
githubPostEnabled: true,
+ // Shows the "Designed share card" opt-in under a cover in /write. Off by default;
+ // set PUBLIC_OG_CARD_OPTIN=true to expose it. Opted-in posts render a per-post OG
+ // card at build (a small build-time cost), so keep it off unless you want it.
+ ogCardOptIn: import.meta.env.PUBLIC_OG_CARD_OPTIN === 'true',
};
export const APPEARANCE = {
diff --git a/src/pages/blog/[slug].astro b/src/pages/blog/[slug].astro
index 045cb45..2dc1771 100644
--- a/src/pages/blog/[slug].astro
+++ b/src/pages/blog/[slug].astro
@@ -45,10 +45,17 @@ const dateISO = post.data.date.toISOString();
const modifiedISO = (post.data.updated ?? post.data.date).toISOString();
const topicLabel = topicName(post.data.topicId);
const cover = post.data.cover;
-// Cover posts share the raw cover; others share one prebuilt brand card. Per-post
-// OG generation is disabled (see src/pages/og/post/[slug].png.ts) to keep build
-// time flat regardless of post count — no render or file per post.
-const ogImage = cover ? (typeof cover === 'string' ? cover : cover.src) : '/og-default.png';
+// Opted-in posts (ogCard + cover) get a generated card; cover posts share the raw
+// cover; the rest share one prebuilt brand card. Per-post generation runs only for
+// the opted-in handful, keeping build time flat (see og/post/[slug].png.ts).
+const ogImage =
+ cover && post.data.ogCard
+ ? `/og/post/${post.id}.png`
+ : cover
+ ? typeof cover === 'string'
+ ? cover
+ : cover.src
+ : '/og-default.png';
const articleJsonLd = {
'@context': 'https://schema.org',
diff --git a/src/pages/og/post/[slug].png.ts b/src/pages/og/post/[slug].png.ts
index c70c35c..a866a2b 100644
--- a/src/pages/og/post/[slug].png.ts
+++ b/src/pages/og/post/[slug].png.ts
@@ -1,5 +1,5 @@
import type { APIRoute } from 'astro';
-import { getEntries } from 'astro:content';
+import { getCollection, getEntries } from 'astro:content';
import type { CollectionEntry } from 'astro:content';
import { readFileSync } from 'fs';
import { join } from 'path';
@@ -7,17 +7,19 @@ import { generateOgPng } from '@/lib/og';
import { pngResponseWithFallback } from '@/lib/og-response';
import { topicName } from '@/lib/data';
-// Per-post OG cards (title/cover composited into a 1200×630 card) are preserved
-// here as an opt-in feature but NOT generated — one render + one file per post
-// doesn't scale (Cloudflare Pages caps a deploy at 20k files) and adds build
-// time that delays the PR preview link. Posts share the raw cover or the single
-// prebuilt /og-default.png instead (see src/pages/blog/[slug].astro).
-//
-// To re-enable: restore the getCollection import and return the mapped posts,
-// then repoint ogImage in blog/[slug].astro back to `/og/post/${post.id}.png`.
-// Best paired with edge/on-demand caching so cards render once, not per build.
+// Per-post OG cards (title composited over the cover) render ONLY for posts that
+// opted in via `ogCard: true` + a cover. Everyone else uses the raw cover or the
+// shared /og-default.png (see blog/[slug].astro), so build time stays flat: one
+// render/file only for the handful that ask for it.
export async function getStaticPaths() {
- return [] as { params: { slug: string }; props: { post: CollectionEntry<'posts'> } }[];
+ const posts = await getCollection(
+ 'posts',
+ ({ data }) => !data.draft && data.ogCard && !!data.cover,
+ );
+ return posts.map((post) => ({
+ params: { slug: post.id },
+ props: { post },
+ }));
}
const MIME: Record = {
diff --git a/src/write/WritePortal.tsx b/src/write/WritePortal.tsx
index bc522a3..20cb4fd 100644
--- a/src/write/WritePortal.tsx
+++ b/src/write/WritePortal.tsx
@@ -111,6 +111,7 @@ function emptyMeta(): PostMeta {
tags: [],
slug: '',
coverFileName: '',
+ ogCard: false,
proposedTopic: '',
newAuthor: null,
};
diff --git a/src/write/editor/editor-theme.css b/src/write/editor/editor-theme.css
index d47f974..8586778 100644
--- a/src/write/editor/editor-theme.css
+++ b/src/write/editor/editor-theme.css
@@ -360,7 +360,25 @@
}
.write-cover-set {
display: flex;
- justify-content: center;
+ flex-direction: column;
+ align-items: center;
+ gap: 10px;
+}
+.write-ogcard-opt {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--ink-2);
+ cursor: pointer;
+}
+.write-ogcard-opt input {
+ cursor: pointer;
+}
+.write-ogcard-info {
+ color: var(--ink-4);
+ cursor: help;
}
.write-cover-frame {
position: relative;
diff --git a/src/write/meta/MetaForm.tsx b/src/write/meta/MetaForm.tsx
index 35f46e3..50dac07 100644
--- a/src/write/meta/MetaForm.tsx
+++ b/src/write/meta/MetaForm.tsx
@@ -1,4 +1,5 @@
import { useState } from 'react';
+import { SITE } from '@/lib/site';
import type { NewAuthor, PostMeta } from '../serialize/toMdx';
import { slugify } from '../serialize/validate';
import { addAsset, getAssetUrl } from '../storage/assets';
@@ -242,6 +243,23 @@ export function MetaForm({ authors, topics, meta, images, onChange }: Props) {
✕
+ {SITE.ogCardOptIn && (
+
+ )}
) : (
diff --git a/src/write/serialize/toMdx.ts b/src/write/serialize/toMdx.ts
index 815890e..46f1845 100644
--- a/src/write/serialize/toMdx.ts
+++ b/src/write/serialize/toMdx.ts
@@ -35,6 +35,9 @@ export type PostMeta = {
tags: string[];
slug: string;
coverFileName: string;
+ // Opt in to a generated share card (title over the cover) instead of the raw
+ // cover. Only meaningful when a cover is set.
+ ogCard?: boolean;
// Set only when editing an existing post; preserves its original publish date.
date?: string;
// A topic the writer proposes that isn't in the list yet — a maintainer (or, later,
@@ -404,6 +407,7 @@ function buildFrontmatter(meta: PostMeta, blocks: SBlock[], opts: SerializeOptio
if (meta.tags.length > 0) lines.push(`tags: [${meta.tags.map(yaml).join(', ')}]`);
if (meta.proposedTopic?.trim()) lines.push(`proposedTopic: ${yaml(meta.proposedTopic.trim())}`);
if (meta.coverFileName) lines.push(`cover: ${yaml(`./${meta.coverFileName}`)}`);
+ if (meta.coverFileName && meta.ogCard) lines.push('ogCard: true');
return `---\n${lines.join('\n')}\n---`;
}
From 101b2a17c8e3c384ae38f445151c9beb1b2daa25 Mon Sep 17 00:00:00 2001
From: Dinesh <13635627+HumbleBee14@users.noreply.github.com>
Date: Tue, 14 Jul 2026 05:09:00 -0700
Subject: [PATCH 3/8] Document PUBLIC_OG_CARD_OPTIN in .env.example
---
.env.example | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/.env.example b/.env.example
index 737761a..966b013 100644
--- a/.env.example
+++ b/.env.example
@@ -9,3 +9,8 @@ PUBLIC_GA_MEASUREMENT_ID=
# Leaving these blank shows the "comments not configured" fallback.
PUBLIC_GISCUS_REPO_ID=
PUBLIC_GISCUS_CATEGORY_ID=
+
+# Show the "Designed share card" opt-in under a cover in /write. Only "true"
+# enables it; anything else (or unset) is treated as off. Opted-in posts render
+# a per-post OG card at build, so leave off unless you want that cost.
+PUBLIC_OG_CARD_OPTIN=true
From faafeea3d75629ec6758a9cb7164d5ad1778c390 Mon Sep 17 00:00:00 2001
From: Dinesh <13635627+HumbleBee14@users.noreply.github.com>
Date: Tue, 14 Jul 2026 05:14:26 -0700
Subject: [PATCH 4/8] Show cover at true 1.91:1 crop in editor + hero; hint on
the label
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Editor cover preview is now a 1200x630 (1.91:1) frame with object-fit:cover, so
authors see the exact crop link previews will show; tip nudges 1200x630
- Article hero uses the same 1.91:1 ratio, so editor = hero = social all match
- Non-ideal covers are center-cropped for display only (file unchanged, no build
cost) — authors see it and can swap
- Replace the broken info glyph with a native title tooltip on the whole label
---
src/styles/global.css | 2 +-
src/write/editor/editor-theme.css | 28 +++++++++++++++++-----------
src/write/meta/MetaForm.tsx | 15 +++++++--------
3 files changed, 25 insertions(+), 20 deletions(-)
diff --git a/src/styles/global.css b/src/styles/global.css
index fb12db8..23f5f05 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -1346,7 +1346,7 @@ a.hashtag:hover {
}
.article-cover img {
width: 100%;
- max-height: 440px;
+ aspect-ratio: 1200 / 630;
object-fit: cover;
height: auto;
border-radius: var(--radius-lg, 10px);
diff --git a/src/write/editor/editor-theme.css b/src/write/editor/editor-theme.css
index 8586778..db653ea 100644
--- a/src/write/editor/editor-theme.css
+++ b/src/write/editor/editor-theme.css
@@ -376,24 +376,30 @@
.write-ogcard-opt input {
cursor: pointer;
}
-.write-ogcard-info {
- color: var(--ink-4);
- cursor: help;
-}
.write-cover-frame {
position: relative;
- display: inline-block;
+ display: block;
line-height: 0;
-}
-.write-cover-frame img {
- max-width: 220px;
- max-height: 150px;
- width: auto;
- height: auto;
+ width: 340px;
+ max-width: 100%;
+ aspect-ratio: 1200 / 630;
border-radius: 8px;
border: 1px solid var(--line-2);
+ overflow: hidden;
+}
+.write-cover-frame img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
display: block;
}
+.write-cover-tip {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--ink-3);
+ text-align: center;
+ max-width: 340px;
+}
.write-cover-remove {
position: absolute;
top: -8px;
diff --git a/src/write/meta/MetaForm.tsx b/src/write/meta/MetaForm.tsx
index 50dac07..730c7b8 100644
--- a/src/write/meta/MetaForm.tsx
+++ b/src/write/meta/MetaForm.tsx
@@ -243,21 +243,20 @@ export function MetaForm({ authors, topics, meta, images, onChange }: Props) {
✕
+
+ This is the crop shown in link previews. Best at 1200×630 (landscape).
+
{SITE.ogCardOptIn && (
-