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
diff --git a/.github/workflows/preview-link.yml b/.github/workflows/preview-link.yml
index e74c1d4..1da0400 100644
--- a/.github/workflows/preview-link.yml
+++ b/.github/workflows/preview-link.yml
@@ -35,7 +35,7 @@ jobs:
const pr = context.payload.issue;
const match = (pr.body || '').match(//);
- const link = match ? `${base}/blog/${match[1]}/` : base;
+ const link = match ? `${base}/blog/${match[1]}` : base;
const marker = '';
const body = `${marker}\n> [!TIP]\n> 📄 **[Preview your post →](${link})**`;
diff --git a/astro.config.mjs b/astro.config.mjs
index 1579777..3a1afa2 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -164,6 +164,10 @@ export default defineConfig({
build: {
inlineStylesheets: 'always',
+ // Flat files (blog/x.html) instead of blog/x/index.html, so URLs stay clean
+ // with no trailing slash (pairs with trailingSlash: 'never'). Avoids
+ // Cloudflare's directory-style 308 redirect that appended the slash.
+ format: 'file',
},
vite: {
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/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro
index 03dfaa6..58df2c1 100644
--- a/src/layouts/BaseLayout.astro
+++ b/src/layouts/BaseLayout.astro
@@ -41,6 +41,8 @@ interface Props {
modifiedTime?: string;
/** Article section (topic) — OG article metadata */
section?: string;
+ /** Article author name(s) — emitted as article:author OG metadata */
+ author?: string;
/** Article tags — emitted as article:tag OG metadata */
tags?: string[];
/** Alt text for the OG image — defaults to the page title */
@@ -60,6 +62,7 @@ const {
publishedTime,
modifiedTime,
section,
+ author,
tags,
imageAlt,
noindex = false,
@@ -167,6 +170,7 @@ const siteJsonLd = [
{publishedTime && }
{modifiedTime && }
{section && }
+ {author && }
{tags?.map((tag) => )}
diff --git a/src/lib/og.tsx b/src/lib/og.tsx
index 4c7c0c0..8dd8af3 100644
--- a/src/lib/og.tsx
+++ b/src/lib/og.tsx
@@ -168,7 +168,7 @@ function coverTemplate(d: OgArticle, coverUrl: string): React.ReactElement {
height: 630,
display: 'flex',
background:
- 'linear-gradient(180deg, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.35) 42%, rgba(0,0,0,0.84) 100%)',
+ 'linear-gradient(180deg, rgba(0,0,0,0.42) 0%, rgba(0,0,0,0.28) 34%, rgba(0,0,0,0.84) 100%)',
}}
/>
-

-
- MLSYSTEMS.DEV
-
+

+
+
+ MLSYSTEMS.DEV
+
+
+ Machine learning, from{' '}
+
+ kernels
+ {' '}
+ to clusters.
+
+
diff --git a/src/pages/og/post/[slug].png.ts b/src/pages/og/post/[slug].png.ts
index 18cf8e9..a866a2b 100644
--- a/src/pages/og/post/[slug].png.ts
+++ b/src/pages/og/post/[slug].png.ts
@@ -7,8 +7,15 @@ import { generateOgPng } from '@/lib/og';
import { pngResponseWithFallback } from '@/lib/og-response';
import { topicName } from '@/lib/data';
+// 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() {
- const posts = await getCollection('posts', ({ data }) => !data.draft);
+ const posts = await getCollection(
+ 'posts',
+ ({ data }) => !data.draft && data.ogCard && !!data.cover,
+ );
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
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/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..11547fb 100644
--- a/src/write/editor/editor-theme.css
+++ b/src/write/editor/editor-theme.css
@@ -360,22 +360,48 @@
}
.write-cover-set {
display: flex;
- justify-content: center;
+ flex-direction: column;
+ align-items: center;
+ gap: 10px;
}
-.write-cover-frame {
+.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-cover-holder {
position: relative;
- display: inline-block;
- line-height: 0;
+ width: 100%;
+ max-width: 640px;
}
-.write-cover-frame img {
- max-width: 220px;
- max-height: 150px;
- width: auto;
- height: auto;
+.write-cover-frame {
+ display: block;
+ line-height: 0;
+ 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;
+}
.write-cover-remove {
position: absolute;
top: -8px;
diff --git a/src/write/meta/MetaForm.tsx b/src/write/meta/MetaForm.tsx
index 35f46e3..362b34d 100644
--- a/src/write/meta/MetaForm.tsx
+++ b/src/write/meta/MetaForm.tsx
@@ -1,8 +1,10 @@
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';
+import { getAsset, getAssetUrl } from '../storage/assets';
import { AuthorModal } from './AuthorModal';
+import { addCroppedCover } from './cropCover';
export type Option = { id: string; name: string };
@@ -230,8 +232,10 @@ export function MetaForm({ authors, topics, meta, images, onChange }: Props) {
{meta.coverFileName ? (
-
-
})
+
+
+
})
+
+
Recommended: wide, landscape (1200×630)
+ {SITE.ogCardOptIn && (
+
+ )}
) : (
@@ -251,7 +269,10 @@ export function MetaForm({ authors, topics, meta, images, onChange }: Props) {
type="button"
className="write-cover-thumb"
title="Use as cover"
- onClick={() => set({ coverFileName: name })}
+ onClick={async () => {
+ const file = getAsset(name);
+ if (file) set({ coverFileName: await addCroppedCover(file) });
+ }}
>
})
@@ -261,9 +282,9 @@ export function MetaForm({ authors, topics, meta, images, onChange }: Props) {
{
+ onChange={async (e) => {
const file = e.target.files?.[0];
- if (file) set({ coverFileName: addAsset(file) });
+ if (file) set({ coverFileName: await addCroppedCover(file) });
}}
/>
diff --git a/src/write/meta/cropCover.ts b/src/write/meta/cropCover.ts
new file mode 100644
index 0000000..300d076
--- /dev/null
+++ b/src/write/meta/cropCover.ts
@@ -0,0 +1,55 @@
+import { addAsset } from '../storage/assets';
+
+const RATIO = 1200 / 630;
+const MAX_W = 1200;
+
+// Center-crop an image to the 1.91:1 share ratio in the browser (HTML canvas).
+// Only the cropped result is stored and uploaded, so what the author previews is
+// exactly what ships — no build-time image processing. Falls back to the original
+// file if anything goes wrong.
+export async function cropToCover(file: File): Promise
{
+ try {
+ const bitmap = await createImageBitmap(file);
+ const { width: sw, height: sh } = bitmap;
+
+ let cw = sw;
+ let ch = Math.round(sw / RATIO);
+ if (ch > sh) {
+ ch = sh;
+ cw = Math.round(sh * RATIO);
+ }
+ const sx = Math.round((sw - cw) / 2);
+ const sy = Math.round((sh - ch) / 2);
+
+ const outW = Math.min(MAX_W, cw);
+ const outH = Math.round(outW / RATIO);
+
+ const canvas = document.createElement('canvas');
+ canvas.width = outW;
+ canvas.height = outH;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) {
+ bitmap.close();
+ return file;
+ }
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(0, 0, outW, outH);
+ ctx.drawImage(bitmap, sx, sy, cw, ch, 0, 0, outW, outH);
+ bitmap.close();
+
+ const blob = await new Promise((resolve) =>
+ canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.9),
+ );
+ if (!blob) return file;
+
+ const stem = file.name.replace(/\.[^.]+$/, '') || 'cover';
+ return new File([blob], `${stem}-cover.jpg`, { type: 'image/jpeg' });
+ } catch {
+ return file;
+ }
+}
+
+// Crop a file to the share ratio and store it, returning the new asset name.
+export async function addCroppedCover(file: File): Promise {
+ return addAsset(await cropToCover(file));
+}
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---`;
}
diff --git a/tsconfig.json b/tsconfig.json
index 9d0b197..910aba0 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -3,7 +3,6 @@
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react",
- "baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}