Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion .github/workflows/preview-link.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:

const pr = context.payload.issue;
const match = (pr.body || '').match(/<!--\s*post-slug:\s*([a-z0-9-]+)\s*-->/);
const link = match ? `${base}/blog/${match[1]}/` : base;
const link = match ? `${base}/blog/${match[1]}` : base;

const marker = '<!-- preview-link-bot -->';
const body = `${marker}\n> [!TIP]\n> 📄 **[Preview your post →](${link})**`;
Expand Down
4 changes: 4 additions & 0 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
4 changes: 4 additions & 0 deletions src/content/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}),
Expand Down
4 changes: 4 additions & 0 deletions src/layouts/BaseLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -60,6 +62,7 @@ const {
publishedTime,
modifiedTime,
section,
author,
tags,
imageAlt,
noindex = false,
Expand Down Expand Up @@ -167,6 +170,7 @@ const siteJsonLd = [
{publishedTime && <meta property="article:published_time" content={publishedTime} />}
{modifiedTime && <meta property="article:modified_time" content={modifiedTime} />}
{section && <meta property="article:section" content={section} />}
{author && <meta property="article:author" content={author} />}
{tags?.map((tag) => <meta property="article:tag" content={tag} />)}

<!-- Twitter -->
Expand Down
48 changes: 33 additions & 15 deletions src/lib/og.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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%)',
}}
/>
<div
Expand All @@ -185,25 +185,43 @@ function coverTemplate(d: OgArticle, coverUrl: string): React.ReactElement {
<div
style={{
position: 'absolute',
top: 60,
top: 58,
left: 68,
display: 'flex',
alignItems: 'center',
gap: 16,
gap: 18,
}}
>
<img src={logo} width={52} height={52} style={{ borderRadius: 8 }} alt="" />
<span
style={{
fontFamily: 'JetBrains Mono',
fontSize: 20,
letterSpacing: 3,
color: '#ffffff',
fontWeight: 600,
}}
>
MLSYSTEMS.DEV
</span>
<img src={logo} width={56} height={56} style={{ borderRadius: 8 }} alt="" />
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span
style={{
fontFamily: 'JetBrains Mono',
fontSize: 20,
letterSpacing: 3,
color: '#ffffff',
fontWeight: 600,
}}
>
MLSYSTEMS.DEV
</span>
<span
style={{
fontFamily: 'Playfair Display',
fontStyle: 'italic',
fontSize: 25,
color: '#ece7db',
letterSpacing: -0.5,
display: 'flex',
}}
>
Machine learning, from{' '}
<span style={{ color: '#e0794f', display: 'flex', marginLeft: 7, marginRight: 7 }}>
kernels
</span>{' '}
to clusters.
</span>
</div>
</div>
<div
style={{
Expand Down
4 changes: 4 additions & 0 deletions src/lib/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
16 changes: 12 additions & 4 deletions src/pages/blog/[slug].astro
Original file line number Diff line number Diff line change
Expand Up @@ -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;
// Always share the generated 1200×630 card — it composites the cover (any shape)
// with a legibility scrim, so previews never crop awkwardly. The raw cover still
// renders as the in-article hero below.
const ogImage = `/og/post/${post.id}.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',
Expand Down Expand Up @@ -99,6 +106,7 @@ const breadcrumbJsonLd = {
publishedTime={dateISO}
modifiedTime={modifiedISO}
section={topicLabel}
author={authorNames}
tags={post.data.tags}
jsonLd={[articleJsonLd, breadcrumbJsonLd]}
>
Expand Down
9 changes: 8 additions & 1 deletion src/pages/og/post/[slug].png.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
2 changes: 1 addition & 1 deletion src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/write/WritePortal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ function emptyMeta(): PostMeta {
tags: [],
slug: '',
coverFileName: '',
ogCard: false,
proposedTopic: '',
newAuthor: null,
};
Expand Down
44 changes: 35 additions & 9 deletions src/write/editor/editor-theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
33 changes: 27 additions & 6 deletions src/write/meta/MetaForm.tsx
Original file line number Diff line number Diff line change
@@ -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 };

Expand Down Expand Up @@ -230,8 +232,10 @@ export function MetaForm({ authors, topics, meta, images, onChange }: Props) {
<div className="write-cover">
{meta.coverFileName ? (
<div className="write-cover-set">
<div className="write-cover-frame">
<img src={getAssetUrl(meta.coverFileName)} alt="" />
<div className="write-cover-holder">
<div className="write-cover-frame">
<img src={getAssetUrl(meta.coverFileName)} alt="" />
</div>
<button
type="button"
className="write-cover-remove"
Expand All @@ -242,6 +246,20 @@ export function MetaForm({ authors, topics, meta, images, onChange }: Props) {
</button>
</div>
<span className="write-cover-tip">Recommended: wide, landscape (1200×630)</span>
{SITE.ogCardOptIn && (
<label
className="write-ogcard-opt"
title="Overlays your post title + brand on the cover for link previews (LinkedIn, X, Slack). Generated when you publish."
>
<input
type="checkbox"
checked={!!meta.ogCard}
onChange={(e) => set({ ogCard: e.target.checked })}
/>
Designed share card
</label>
)}
</div>
) : (
<div className="write-cover-pick">
Expand All @@ -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) });
}}
>
<img src={getAssetUrl(name)} alt="" />
</button>
Expand All @@ -261,9 +282,9 @@ export function MetaForm({ authors, topics, meta, images, onChange }: Props) {
<input
type="file"
accept="image/*"
onChange={(e) => {
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) set({ coverFileName: addAsset(file) });
if (file) set({ coverFileName: await addCroppedCover(file) });
}}
/>
</label>
Expand Down
55 changes: 55 additions & 0 deletions src/write/meta/cropCover.ts
Original file line number Diff line number Diff line change
@@ -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<File> {
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<Blob | null>((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<string> {
return addAsset(await cropToCover(file));
}
Loading
Loading