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/*"] }