From 66d52c3e50c62e865d0728e7bd9de3776b16ac80 Mon Sep 17 00:00:00 2001 From: "Pre.dev Agent" Date: Mon, 26 Oct 2026 14:11:22 -0400 Subject: [PATCH 1/2] feat(cards): add a left share card design, every-post cards and page-name og:title A site can now pick its share card design with appearance.shareCards: - design: 'centered' (the default, unchanged) or 'left'. The left design sets the site's logo and a trail of mono capitals on top (site, section, then the post's topic, "Topic" or "Author"), the headline and one support line, and a footer band with the byline and date or a post count on the left and the page's address on the right, on a sky of still stars. An author card shows the portrait, or an initial, in a circle. Text is fitted with satori's own measurements: a long title steps down from 64px to 48px, and support copy keeps whole sentences. It loads Inter 500/600/800 and JetBrains Mono 500/700 from fontsource, kept in R2 like Inter is today. - everyPost: posts and pages with a cover are shared with a drawn card too. The cover stays on the page and in its structured data; an ogImage set by hand still wins. The editor's share preview follows the setting. - home: the front page card's headline, a dimmer second line and its text. Shared pages now go by their own name: og:title is a post's ogTitle or title (its metaTitle stays in ), a topic's ogTitle or name, an author's name, and the front page's ogTitle, masthead title or site title. Before, topics and authors were shared as "Name - Site" by default. og:image:alt on drawn cards is the headline, then the support line, at most 420 characters. CARD_DESIGN goes to 2, so every card is drawn again under a new address. The Worker stays on satori 0.32: 0.33 loads HarfBuzz WebAssembly at import through fs or a script-relative fetch, which a Worker without nodejs_compat cannot do. 0.32 spaces a run's last letter from what follows, so the trail's word margins take that back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/architecture.md | 2 +- docs/configuration.md | 3 +- packages/admin/src/api.ts | 3 +- packages/admin/src/pages/post-tools.tsx | 3 +- packages/admin/src/pages/site-design.tsx | 37 ++ packages/core/src/types.ts | 35 +- packages/render/src/index.ts | 4 +- packages/render/src/site.ts | 112 +++++- packages/server/src/admin.ts | 15 +- packages/server/src/card-layout.ts | 4 +- packages/server/src/card-left.ts | 414 +++++++++++++++++++++++ packages/server/src/cards.ts | 59 +++- 12 files changed, 656 insertions(+), 35 deletions(-) create mode 100644 packages/server/src/card-left.ts diff --git a/docs/architecture.md b/docs/architecture.md index 42073a9..4f887f0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,7 +19,7 @@ flowchart LR ## Reading -A request for a page maps to a file in R2 (`/blog/some-post/` is `site/blog/some-post/index.html`) and is answered with its ETag and Last-Modified, so repeat visits get `304 Not Modified`. On a custom domain, Cloudflare's cache sits in front. The read path never queries D1: a traffic spike costs R2 reads, not database load. Media under `content/` is served from R2 with range requests, so video seeks, and WebP copies at `srcset` widths are made on first request when the Images binding is present. So are share cards (`content/cards/<key>.png`) for pages without an image: drawn with satori and resvg the first time someone asks, then kept in R2. +A request for a page maps to a file in R2 (`/blog/some-post/` is `site/blog/some-post/index.html`) and is answered with its ETag and Last-Modified, so repeat visits get `304 Not Modified`. On a custom domain, Cloudflare's cache sits in front. The read path never queries D1: a traffic spike costs R2 reads, not database load. Media under `content/` is served from R2 with range requests, so video seeks, and WebP copies at `srcset` widths are made on first request when the Images binding is present. So are share cards (`content/cards/<key>.png`) for pages without an image (or for every post, with `appearance.shareCards.everyPost`): drawn with satori and resvg the first time someone asks, in the site's design (`packages/server/src/card-layout.ts`, centered, or `card-left.ts`), then kept in R2. Hosts other than `SITE_URL`'s answer with `x-robots-tag: noindex`, so staging and workers.dev copies never compete with the real site. `PREVIEW_PATH` serves the whole site at a second path with links rewritten on the way out, for trying Masthead on a domain whose main path still belongs to another blog. diff --git a/docs/configuration.md b/docs/configuration.md index daa8c95..4815749 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -126,7 +126,7 @@ It saves `site`, `newsletter`, `ai`, `style` and `workflow`, adds `memory` entri | Field | What it does | | --- | --- | | `title`, `description`, `locale` | The publication's name, one-line description and language (default `en`). | -| `metaTitle`, `metaDescription`, `ogTitle`, `ogDescription` | Title, description and share card for the front page, when they differ from the above. | +| `metaTitle`, `metaDescription`, `ogTitle`, `ogDescription` | Title, description and share card for the front page, when they differ from the above. Shared, a page goes by its own name (`og:title`): the front page by `ogTitle`, else `appearance.hero.title`, else `title`; a post by its `ogTitle` or title, a topic by its `ogTitle` or name, an author by their name. `metaTitle` stays in `<title>`. | | `logo`, `icon`, `shareImage` | Logo in the header and footer, favicon, and the default share image for pages without their own. | | `accentColor` | Brand color for links and buttons. | | `twitter` | The publication's X handle, for share cards. | @@ -138,6 +138,7 @@ It saves `site`, `newsletter`, `ai`, `style` and `workflow`, adds `memory` entri | `appearance.backdrop` | A backdrop behind the header: an `image`, an optional `mobileImage` and twinkling `sparkles`. Inverted in the light scheme. | | `appearance.headerCta` | A button at the end of the header, with an optional `signedIn` variant chosen by a cookie your product sets. | | `appearance.subscribe` | Title and text of the signup band. | +| `appearance.shareCards` | The share cards drawn for pages without a share image of their own. `design`: `centered` (the default; the logo and title on the night sky) or `left` (the logo and a trail such as `ACME · BLOG · TOPIC` on top, the headline and a support line, then the byline or post count and the page's address under a hairline, on a sky of still stars). `everyPost`: posts and pages with a cover image are shared with a card too; the cover stays on the page, and an `ogImage` set on the post still wins. `home`: the front page card's `title` (default: `appearance.hero.title`, then `title`), a dimmer second line `dim` (left design) and `text` (default: the front page's share description). | | `publisher` | The organization in structured data: `name`, `url`, `logo`, `sameAs`, `description`, `knowsAbout`. Defaults to the site. Every page carries it as a short entity with a stable `@id`; the front page carries the full one. | | `about` | A short paragraph on who publishes the blog and what they do. It leads `llms.txt` and `llms-full.txt`, closes every post's Markdown copy, and is the RSS channel description, so a model that reads any one of them knows who is speaking. | | `offerings` | What the publisher offers, as `{ name, url, description }`. Listed under About in `llms.txt` and published as offers on the organization. | diff --git a/packages/admin/src/api.ts b/packages/admin/src/api.ts index 485cf94..47c2ac8 100644 --- a/packages/admin/src/api.ts +++ b/packages/admin/src/api.ts @@ -5,7 +5,8 @@ export const base = location.pathname.replace(/admin(\/.*)?$/, ''); export interface Me { user: { staffId: string; email: string; name: string; role: 'owner' | 'admin' | 'editor' | 'author' | 'contributor'; via: string }; - site: { title: string; url: string; icon: string | null }; + /** `everyPostCard`: posts are shared with a drawn card even when they have a cover (appearance.shareCards.everyPost). */ + site: { title: string; url: string; icon: string | null; everyPostCard?: boolean }; appUrl: string; testMode: boolean; } diff --git a/packages/admin/src/pages/post-tools.tsx b/packages/admin/src/pages/post-tools.tsx index eb49148..88bb22b 100644 --- a/packages/admin/src/pages/post-tools.tsx +++ b/packages/admin/src/pages/post-tools.tsx @@ -85,7 +85,8 @@ export function SearchPanel(props: { draft: { id?: string; title: string; slug: const d = props.draft; const checks = seoChecks(d, props.getHtml()); const passed = checks.filter(c => c.ok).length; - const image = d.ogImage || d.featureImage; + // A share image set by hand wins; the cover is shared only when posts don't all get a card. + const image = d.ogImage || (session.value?.site.everyPostCard ? null : d.featureImage); // Without an image the post is shared with a drawn card: show it, redrawn once the title settles. const [cardTitle, setCardTitle] = useState(d.ogTitle || d.title); useEffect(() => { diff --git a/packages/admin/src/pages/site-design.tsx b/packages/admin/src/pages/site-design.tsx index 8f8f26c..9c0c9af 100644 --- a/packages/admin/src/pages/site-design.tsx +++ b/packages/admin/src/pages/site-design.tsx @@ -30,6 +30,13 @@ interface Appearance { headerCta?: { label: string; url: string; signedIn?: { cookie: string; label: string; url: string } | null } | null; hero?: { title?: string | null; text?: string | null } | null; subscribe?: { title?: string | null; text?: string | null } | null; + shareCards?: ShareCards | null; +} + +interface ShareCards { + design?: 'centered' | 'left'; + everyPost?: boolean; + home?: { title?: string | null; dim?: string | null; text?: string | null } | null; } const ICONS = ['', 'terminal', 'window', 'workflow', 'pointer', 'flask', 'book', 'rss', 'mail']; @@ -201,7 +208,11 @@ export function AppearanceEditor({ value, onChange }: { value: Appearance; onCha const cta = value.headerCta ?? { label: '', url: '' }; const hero = value.hero ?? {}; const sub = value.subscribe ?? {}; + const cards = value.shareCards ?? {}; + const home = cards.home ?? {}; const setCta = (c: typeof cta) => onChange({ ...value, headerCta: c.label || c.url ? c : null }); + const setCards = (c: ShareCards) => onChange({ ...value, shareCards: c }); + const setHome = (h: NonNullable<ShareCards['home']>) => setCards({ ...cards, home: h.title || h.dim || h.text ? h : null }); return ( <div class="stack"> <div class="grid2"> @@ -271,6 +282,32 @@ export function AppearanceEditor({ value, onChange }: { value: Appearance; onCha </Field> </div> </Group> + <Group title="Share cards"> + <p class="muted small">The picture link previews show for pages without a share image of their own.</p> + <div class="grid2"> + <Field label="Design"> + <select value={cards.design ?? 'centered'} onChange={e => setCards({ ...cards, design: e.currentTarget.value as ShareCards['design'] })}> + <option value="centered">Centered: the logo and title on the night sky</option> + <option value="left">Left: a trail on top, the headline, a byline and address below</option> + </select> + </Field> + </div> + <label class="check"> + <input type="checkbox" checked={!!cards.everyPost} onChange={e => setCards({ ...cards, everyPost: e.currentTarget.checked })} /> Share every post with a card, also + posts with a cover image (a share image set on the post still wins) + </label> + <div class="grid2"> + <Field label="Front page card headline" hint="Blank: the masthead title."> + <input value={home.title ?? ''} placeholder="News from Acme" onInput={e => setHome({ ...home, title: text(e.currentTarget.value) })} /> + </Field> + <Field label="Second line, set dimmer" hint="Left design. Blank: none."> + <input value={home.dim ?? ''} onInput={e => setHome({ ...home, dim: text(e.currentTarget.value) })} /> + </Field> + <Field label="Front page card text" hint="Blank: the front page's share description."> + <input value={home.text ?? ''} onInput={e => setHome({ ...home, text: text(e.currentTarget.value) })} /> + </Field> + </div> + </Group> <Group title="Newsletter signup"> <div class="grid2"> <Field label="Heading"> diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6c70a4a..7478885 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -167,6 +167,28 @@ export interface Appearance { } | null; /** Heading and text of the newsletter signup band. */ subscribe?: { title?: string | null; text?: string | null } | null; + /** The share cards drawn for pages without their own share image. */ + shareCards?: ShareCardSettings | null; +} + +export interface ShareCardSettings { + /** + * `centered` (the default): the logo and the page's title in the middle of the site's night sky. + * `left`: the logo and a trail of small capitals on top (site, section, the page's topic or kind), + * the headline and one support line set left, and a footer band with the byline or post count on + * the left and the page's address on the right, on a sky of still stars. + */ + design?: 'centered' | 'left'; + /** + * Every post and page is shared with a drawn card, also when it has a cover image (the cover + * stays on the page and in its structured data). A share image set by hand (ogImage) still wins. + */ + everyPost?: boolean; + /** + * The front page's card. `title` defaults to the masthead title, then the site title; `dim` + * is a second headline line set dimmer (left design); `text` defaults to the share description. + */ + home?: { title?: string | null; dim?: string | null; text?: string | null } | null; } export interface SiteSettings { @@ -248,10 +270,17 @@ export interface ShareCard { date?: string | null; /** A line under the title: the blog's tagline, a topic's or author's description. */ text?: string | null; - /** The bottom line, e.g. "Jane Doe · 6 min read". */ + /** + * The bottom line, e.g. "Jane Doe · 6 min read". On the left design: the byline alone (the + * card adds the date) or a post count, e.g. "9 posts". + */ meta?: string | null; /** The author's portrait on author cards. */ image?: string | null; + /** A second headline line, set dimmer (left design). */ + dim?: string | null; + /** The address the card shows, e.g. "example.com/blog/tag/news" (left design). */ + address?: string | null; } /** What every card shares: the lockup and the sky. */ @@ -264,6 +293,10 @@ export interface ShareCardSite { backdrop?: string | null; locale: string; url: string; + /** The card design (appearance.shareCards.design); centered when unset. */ + design?: 'centered' | 'left'; + /** The left design's trail before each card's own part, e.g. ["Acme", "Blog"]. */ + trail?: string[]; } /** The cards of a published site by key; the server draws content/cards/<key>.png from it. */ diff --git a/packages/render/src/index.ts b/packages/render/src/index.ts index 018d18e..fd31334 100644 --- a/packages/render/src/index.ts +++ b/packages/render/src/index.ts @@ -7,5 +7,5 @@ export { headTags, blogPostingLd, breadcrumbLd, blogLd, collectionLd, profileLd, export { rss, urlset, sitemapIndex } from './feeds'; export { llmsFull, llmsTxt, markdownCopy } from './llms'; export type { LlmsEntry, LlmsLinks } from './llms'; -export { escapeHtml, plainText, autoExcerpt, readingMinutes, wordCount, tagLinks, shortHash } from './util'; -export { postShareCard, shareCardSite, themeAssetsVersion } from './site'; +export { escapeHtml, plainText, autoExcerpt, readingMinutes, sentenceSummary, wordCount, tagLinks, shortHash } from './util'; +export { postShareCard, shareCardAlt, shareCardSite, shownAddress, themeAssetsVersion } from './site'; diff --git a/packages/render/src/site.ts b/packages/render/src/site.ts index 32bc3a5..ad7c1a9 100644 --- a/packages/render/src/site.ts +++ b/packages/render/src/site.ts @@ -152,6 +152,8 @@ export async function* renderSite(snapshot: Snapshot, options: BuildOptions, bod // Share cards for pages without their own image. A card's address changes with anything it // shows (its title, the logo, the design), so link previews pick up a new title. const shareCards = options.features?.shareCards; + const cardSettings = site.appearance?.shareCards ?? {}; + const leftCards = cardSettings.design === 'left'; const cards: Record<string, ShareCard> = {}; const cardSite = shareCardSite(site); const cardSiteKey = JSON.stringify([shareCards?.version, cardSite]); @@ -159,7 +161,7 @@ export async function* renderSite(snapshot: Snapshot, options: BuildOptions, bod if (!shareCards) return null; const key = `${name.toLowerCase().replace(/[^a-z0-9-]+/g, '-').slice(0, 80)}-${shortHash(cardSiteKey + JSON.stringify(card))}`; cards[key] = card; - return { url: abs(`${basePath}content/cards/${key}.png`), size: CARD_SIZE, alt: card.title }; + return { url: abs(`${basePath}content/cards/${key}.png`), size: CARD_SIZE, alt: shareCardAlt(card) }; }; /** A description for a topic or author page that has none: what it is, then its newest post titles. */ const collectionSummary = (lead: string, items: { title: string }[]) => { @@ -294,8 +296,9 @@ export async function* renderSite(snapshot: Snapshot, options: BuildOptions, bod const authors = postAuthors(p); const description = fitMeta(f.summary || site.description); const coverSize = imageSizeOf(p.featureImage); - const shareImage = p.ogImage || p.featureImage; - const card = shareImage ? null : shareCard(p.slug, postShareCard(p, tags, authors, f.minutes)); + // With every post on a card, only a share image set by hand stands in for it; the cover stays on the page. + const shareImage = p.ogImage || (cardSettings.everyPost ? null : p.featureImage); + const card = shareImage ? null : shareCard(p.slug, postShareCard(p, tags, authors, f.minutes, { site, summary: f.summary })); const view: PostView = { post: p, url: urls.post(p), @@ -335,7 +338,8 @@ export async function* renderSite(snapshot: Snapshot, options: BuildOptions, bod canonical: p.canonicalUrl || url, head: headTags({ site: metaSite, - title: p.ogTitle || title, + // Shared, a post goes by its own name; the search title (metaTitle) stays in <title>. + title: p.ogTitle || p.title, description: p.ogDescription || description, canonical: p.canonicalUrl || url, type: p.type === 'post' ? 'article' : 'website', @@ -460,11 +464,11 @@ export async function* renderSite(snapshot: Snapshot, options: BuildOptions, bod title: site.metaTitle || site.title, pagedTitle: site.metaTitle || site.title, description: site.metaDescription || site.description, - shareTitle: site.ogTitle, + shareTitle: site.ogTitle || site.appearance?.hero?.title || site.title, shareDescription: homeShareDescription(site), type: 'website', // The front page's share image is the site's, when it has one. - card: site.shareImage ? undefined : { name: 'home', card: { kind: 'home', title: site.appearance?.hero?.title || site.title, text: homeShareDescription(site) || site.description } }, + card: site.shareImage ? undefined : { name: 'home', card: homeShareCard(site, posts.length) }, jsonLd: [blogLd(metaSite), websiteLd(metaSite, abs(urls.search)), organizationLd(metaSite)] })) (yield file, count++); @@ -478,12 +482,18 @@ export async function* renderSite(snapshot: Snapshot, options: BuildOptions, bod heading: t.name, title: t.metaTitle || `${t.name} - ${site.title}`, description: t.description || collectionSummary(`Posts about ${t.name} from ${metaSite.title}`, byTag.get(t.id)!), - shareTitle: t.ogTitle, + // Shared, a topic goes by its name alone; <title> keeps the site's name after it. + shareTitle: t.ogTitle || t.name, shareDescription: t.ogDescription, type: 'website', image: tagImage, imageAlt: t.featureImageAlt, - card: tagImage ? undefined : { name: `tag-${t.slug}`, card: { kind: 'tag', title: t.name, eyebrow: 'Topic', text: t.description, meta: postCount(byTag.get(t.id)!.length) } }, + card: tagImage + ? undefined + : { + name: `tag-${t.slug}`, + card: { kind: 'tag', title: t.name, eyebrow: 'Topic', text: t.description, meta: postCount(byTag.get(t.id)!.length), ...(leftCards ? { address: shownAddress(abs(urls.tag(t))) } : {}) } + }, tag: t, seo: { title: t.metaTitle, description: t.metaDescription, twitterTitle: t.twitterTitle, twitterDescription: t.twitterDescription, twitterImage: t.twitterImage, canonical: t.canonicalUrl, noindex: t.noindex }, jsonLd: [collectionLd(t.metaTitle || t.name, abs(urls.tag(t)), fitMeta(seoDescription), absolute(tagImage))] @@ -498,9 +508,23 @@ export async function* renderSite(snapshot: Snapshot, options: BuildOptions, bod heading: a.name, title: a.metaTitle || `${a.name} - ${site.title}`, description: a.bio || collectionSummary(`Posts by ${a.name} on ${metaSite.title}`, byAuthor.get(a.id)!), + shareTitle: a.name, type: 'profile', image: authorImage, - card: a.ogImage ? undefined : { name: `author-${a.slug}`, card: { kind: 'author', title: a.name, eyebrow: 'Author', text: a.bio, image: absolute(a.profileImage), meta: postCount(byAuthor.get(a.id)!.length) } }, + card: a.ogImage + ? undefined + : { + name: `author-${a.slug}`, + card: { + kind: 'author', + title: a.name, + eyebrow: 'Author', + text: a.bio, + image: absolute(a.profileImage), + meta: postCount(byAuthor.get(a.id)!.length), + ...(leftCards ? { address: shownAddress(abs(urls.author(a))) } : {}) + } + }, author: a, seo: { title: a.metaTitle, description: a.metaDescription, noindex: a.noindex }, jsonLd: [profileLd({ ...a, profileImage: absolute(a.profileImage) }, abs(urls.author(a)))] @@ -674,16 +698,60 @@ function headingIds(html: string): string { }); } -/** What every share card of a site shows: its lockup and sky, with absolute addresses. */ +/** What every share card of a site shows: its lockup and sky (or, on the left design, its trail), with absolute addresses. */ export function shareCardSite(site: SiteSettings): ShareCardSite { const origin = new URL(site.url).origin; const absolute = (u: string | null | undefined) => (u && u.startsWith('/') && !u.startsWith('//') ? `${origin}${u}` : (u ?? null)); - return { title: site.title, wordmark: site.appearance?.hero?.title || null, logo: absolute(site.logo), backdrop: absolute(site.appearance?.backdrop?.image), locale: site.locale, url: site.url }; + const wordmark = site.appearance?.hero?.title || null; + if (site.appearance?.shareCards?.design === 'left') { + // The left design draws its own sky: the backdrop stays out, so changing it redraws no card. + return { title: site.title, wordmark, logo: absolute(site.logo), locale: site.locale, url: site.url, design: 'left', trail: cardTrail(site.title, wordmark) }; + } + return { title: site.title, wordmark, logo: absolute(site.logo), backdrop: absolute(site.appearance?.backdrop?.image), locale: site.locale, url: site.url }; +} + +/** + * The left design's trail before each card's own part: the site's name and what it is, read from the + * masthead title the way the front page sets it ("Acme blog" gives Acme, blog). A masthead title of + * its own stands alone; without one the trail is the site title and "Blog". + */ +function cardTrail(title: string, wordmark: string | null): string[] { + const words = wordmark?.trim(); + if (!words) return [title, 'Blog']; + if (title && words.toLowerCase().startsWith(`${title.toLowerCase()} `)) return [title, words.slice(title.length + 1).trim()]; + return [words]; } -/** A post's share card: its title, primary topic, date and byline (a page shows only its title). */ -export function postShareCard(p: Post, tags: Tag[], authors: Author[], minutes: number): ShareCard { +/** An address as a card shows it: no scheme, "www." or trailing slash, e.g. "example.com/blog/tag/news". */ +export function shownAddress(url: string): string { + let shown = url.replace(/^https?:\/\//i, '').replace(/^www\./i, '').replace(/[?#].*$/, '').replace(/\/+$/, ''); + try { + shown = decodeURI(shown); + } catch { + // Keep it encoded. + } + return shown; +} + +/** + * A post's share card: its title, primary topic, date and byline (a page shows only its title). On the + * left design it also carries a support line (`summary`, the page's description, unless the post has + * whole share or excerpt text of its own) and the blog's address. + */ +export function postShareCard(p: Post, tags: Tag[], authors: Author[], minutes: number, extra: { site?: SiteSettings; summary?: string | null } = {}): ShareCard { const post = p.type === 'post'; + if (extra.site?.appearance?.shareCards?.design === 'left') { + const text = [p.ogDescription, p.customExcerpt, p.metaDescription].find(t => t?.trim() && !isTruncated(t)) || extra.summary || null; + return { + kind: 'post', + title: p.ogTitle || p.title, + eyebrow: post ? tags[0]?.name : null, + date: post ? p.publishedAt : null, + meta: post ? authors.map(a => a.name).join(', ') || null : null, + text, + address: shownAddress(extra.site.url) + }; + } return { kind: 'post', title: p.ogTitle || p.title, @@ -693,6 +761,24 @@ export function postShareCard(p: Post, tags: Tag[], authors: Author[], minutes: }; } +/** The front page's share card: appearance.shareCards.home, else the masthead and the share description. */ +function homeShareCard(site: SiteSettings, posts: number): ShareCard { + const home = site.appearance?.shareCards?.home ?? {}; + const title = home.title?.trim() || site.appearance?.hero?.title || site.title; + const text = home.text?.trim() || homeShareDescription(site) || site.description; + if (site.appearance?.shareCards?.design !== 'left') return { kind: 'home', title, text }; + return { kind: 'home', title, dim: home.dim?.trim() || null, text, meta: posts === 1 ? '1 post' : `${posts} posts`, address: shownAddress(site.url) }; +} + +/** A share card's alt text: its headline, then its support line. At most 420 characters (X's limit). */ +export function shareCardAlt(card: ShareCard): string { + const sentence = (t: string) => (/[.!?,:]$/.test(t) ? t : `${t}.`); + const parts = [sentence([card.title, card.dim].filter(Boolean).join(' ')), (card.text ?? '').replace(/\*\*/g, '')]; + let alt = parts.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim(); + if (alt.length > 420) alt = `${alt.slice(0, 419).replace(/\s+\S*$/, '')}\u2026`; + return alt; +} + /** Changes whenever the theme's stylesheet or files change, so their URLs can be cached for good. */ export function themeAssetsVersion(theme: Theme): string { return shortHash(theme.css + (theme.assets ?? []).map(a => (typeof a.contents === 'string' ? a.contents : String(a.contents.length))).join('')); diff --git a/packages/server/src/admin.ts b/packages/server/src/admin.ts index e79e8e5..7babb1b 100644 --- a/packages/server/src/admin.ts +++ b/packages/server/src/admin.ts @@ -1,5 +1,5 @@ import type { AspectRatio, ModelKind, Post, StaffRole } from '@masthead/core'; -import { postShareCard, readingMinutes, renderBody, renderSite, shareCardSite, tagLinks } from '@masthead/render'; +import { postShareCard, readingMinutes, renderBody, renderSite, sentenceSummary, shareCardSite, tagLinks } from '@masthead/render'; import { addIdeas, assist, draft, draftIdea, draftIdeaStream, draftStream, edit, editStream, image, imageStream, listIdeas, listModels, meta, metaStream, saveIdeaDraft, startVideo, unfurl, videoStatus } from './ai'; import { autoTag, tagUntagged, wantsAutoTags } from './autotag'; import { backupRoutes } from './backup'; @@ -144,7 +144,12 @@ export function adminRoutes(): Router<A> { r.get('/me', async (_req, ctx) => { const p = me(ctx); const site = await siteSettings(ctx.env, ctx.db); - return json({ user: p, site: { title: site.title, url: site.url, icon: site.icon ?? null }, appUrl: appUrl(ctx.env), testMode: testMode(ctx.env) }); + return json({ + user: p, + site: { title: site.title, url: site.url, icon: site.icon ?? null, everyPostCard: Boolean(site.appearance?.shareCards?.everyPost) }, + appUrl: appUrl(ctx.env), + testMode: testMode(ctx.env) + }); }); // ---------------------------------------------------------- posts @@ -309,7 +314,7 @@ export function adminRoutes(): Router<A> { }); /** - * The share card a post without a cover gets, as the editor has it now: `title` is the title being + * The share card a post without a cover (or every post, with appearance.shareCards.everyPost) gets, as the editor has it now: `title` is the title being * typed. Drawn on request and never stored (the site's cards are drawn from what is published). */ r.get('/posts/:id/card', async (_req, ctx, { id }) => { @@ -318,11 +323,13 @@ export function adminRoutes(): Router<A> { await canEdit(ctx, post); const [site, tags, staff] = await Promise.all([siteSettings(ctx.env, ctx.db), listTags(ctx.db), listStaff(ctx.db)]); const title = (ctx.url.searchParams.get('title') ?? '').trim().slice(0, 300); + const rendered = renderBody(post); const card = postShareCard( { ...post, title: title || post.title, ogTitle: title ? null : post.ogTitle, publishedAt: post.publishedAt ?? now() }, post.tags.map(t => tags.find(x => x.id === t)).filter((t): t is (typeof tags)[number] => t?.visibility === 'public'), post.authors.map(a => staff.find(s => s.id === a)).filter((a): a is (typeof staff)[number] => !!a), - readingMinutes(renderBody(post)) + readingMinutes(rendered), + { site, summary: sentenceSummary(rendered) } ); const { paintCard } = await import('./cards'); return new Response(await paintCard(ctx, shareCardSite(site), card), { headers: { 'content-type': 'image/png', 'cache-control': 'private, max-age=300' } }); diff --git a/packages/server/src/card-layout.ts b/packages/server/src/card-layout.ts index 81e2809..47d9949 100644 --- a/packages/server/src/card-layout.ts +++ b/packages/server/src/card-layout.ts @@ -11,8 +11,8 @@ import type { ShareCard, ShareCardSite } from '@masthead/core'; export const CARD_WIDTH = 1200; export const CARD_HEIGHT = 630; -/** Part of every card's address: change it with the design, and every card is drawn again. */ -export const CARD_DESIGN = '1'; +/** Part of every card's address, for both designs (centered here, left in card-left.ts): change it with either, and every card is drawn again. */ +export const CARD_DESIGN = '2'; export interface CardImage { /** A data: URL satori can embed (PNG, JPEG or SVG). */ diff --git a/packages/server/src/card-left.ts b/packages/server/src/card-left.ts new file mode 100644 index 0000000..7cd4219 --- /dev/null +++ b/packages/server/src/card-left.ts @@ -0,0 +1,414 @@ +/** + * The left share card design (appearance.shareCards.design = 'left'): 1200 x 630 on a sky of still + * stars. The site's logo and a trail of small mono capitals on top, the headline and one support + * line set left, then a footer band under a hairline: the byline or post count on the left, the + * page's address on the right. An author's card has their portrait (or initial) in a circle. + * + * Trees of elements for satori, like card-layout.ts; no dependencies, so the design can be tried + * out anywhere. Text is fitted with satori's own measurements (fitCard), never cut mid-sentence: + * a long title steps down from 64px to 48px, and support copy that does not fit loses whole + * sentences. Nothing is drawn under 28px, so the card still reads at a feed's 0.42x scale. + */ +import type { ShareCard, ShareCardSite } from '@masthead/core'; +import { type CardImage, type CardNode, cardDate } from './card-layout'; + +export const CARD_WIDTH = 1200; +export const CARD_HEIGHT = 630; +export const MIN_FONT = 28; + +/** The fonts this design loads: fontsource packages, their version on the CDN, and the files' weights (WOFF: satori reads no WOFF2). */ +export const LEFT_FONTS = { + sans: { family: 'Inter', pkg: 'inter', version: '5.3.0', weights: [500, 600, 800] }, + mono: { family: 'JetBrains Mono', pkg: 'jetbrains-mono', version: '5.3.0', weights: [500, 700] } +} as const; + +const PAD = 72; +const INNER = CARD_WIDTH - PAD * 2; +const SANS = LEFT_FONTS.sans.family; +const MONO = LEFT_FONTS.mono.family; +const INK = '#ffffff'; +const DIM = '#7d7d7d'; +const SOFT = '#a5a5a5'; +const MUTE = '#8f8f8f'; +const STRONG = '#e8e8e8'; +const LABEL = '#c4c4c4'; +const RULE = 'rgba(255,255,255,0.09)'; + +const TITLE_SIZES = [64, 60, 56, 52, 48]; +const TEXT_SIZE = 31; +const TEXT_LINE = 1.42; +const TITLE_LINE = 1.06; +const MARK_HEIGHT = 34; +/** A wide logo (a wordmark) is drawn no wider than this, so the trail keeps its room. */ +const MARK_MAX_WIDTH = 240; +const AVATAR = 132; + +/** What the design draws: the site and page reduced to its parts. */ +export interface LeftCard { + /** The trail after the logo, e.g. ["Acme", "Blog", "Product"]. */ + eyebrow: string[]; + title: string; + /** A second headline line, set dimmer. */ + dim?: string | null; + text?: string | null; + /** The footer band: left (byline and date, or a count) and right (the address). */ + meta: [string, string]; + /** An initial for an author without a portrait; a card with one leaves room for the circle. */ + avatar?: string | null; + /** Balance the title's lines (posts). */ + balance?: boolean; +} + +/** What fitCard chose. */ +export interface LeftFit { + titleSize?: number; + title?: string; + text?: string | null; + textSize?: number; + eyebrow?: string[]; + meta?: [string, string]; +} + +export interface LeftArt { + sky: string; + mark?: CardImage | null; + portrait?: CardImage | null; +} + +/** A page's card as this design draws it. */ +export function leftCard(site: ShareCardSite, card: ShareCard): LeftCard { + const trail = site.trail?.length ? site.trail : [site.title, 'Blog']; + const date = card.kind === 'post' ? cardDate(card.date, site.locale) : ''; + return { + eyebrow: [...trail, card.eyebrow].filter((p): p is string => !!p?.trim()), + title: card.title, + dim: card.dim, + text: card.text, + meta: [[card.meta, date].filter(Boolean).join(' · '), card.address ?? ''], + avatar: card.kind === 'author' ? ([...card.title.trim()][0]?.toUpperCase() ?? null) : null, + balance: card.kind === 'post' + }; +} + +// ------------------------------------------------------------------ art + +/** + * The sky every card sits on: 260 still stars from a fixed seed and a soft light from the top + * left, as one SVG. + */ +export function skySvg(): string { + let seed = 0x5eed2026; + const rnd = () => { + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + const radii = [0.6, 0.7, 0.8, 0.9, 1.0, 1.2, 1.4]; + let stars = ''; + for (let i = 0; i < 260; i++) { + const x = (rnd() * CARD_WIDTH).toFixed(1); + const y = (rnd() * CARD_HEIGHT).toFixed(1); + const r = radii[Math.floor(rnd() * radii.length)]; + stars += `<circle cx="${x}" cy="${y}" r="${r}" fill="#fff" fill-opacity="${(0.11 + rnd() * 0.49).toFixed(2)}"/>`; + } + return ( + `<svg xmlns="http://www.w3.org/2000/svg" width="${CARD_WIDTH}" height="${CARD_HEIGHT}" viewBox="0 0 ${CARD_WIDTH} ${CARD_HEIGHT}">` + + '<defs><radialGradient id="l" gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(168 -50.4) scale(880 520)">' + + '<stop offset="0" stop-color="#fff" stop-opacity="0.09"/><stop offset="0.64" stop-color="#fff" stop-opacity="0"/></radialGradient></defs>' + + `<rect width="${CARD_WIDTH}" height="${CARD_HEIGHT}" fill="#000"/>${stars}<rect width="${CARD_WIDTH}" height="${CARD_HEIGHT}" fill="url(#l)"/></svg>` + ); +} + +/** An SVG string as a data: URL satori embeds. */ +export function svgDataUrl(svg: string): string { + const bytes = new TextEncoder().encode(svg); + let bin = ''; + for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + return `data:image/svg+xml;base64,${btoa(bin)}`; +} + +// ------------------------------------------------------------------ elements + +type Style = Record<string, string | number>; +type Child = CardNode | string | null | undefined | false | '' | Child[]; + +function el(type: string, style: Style, ...children: Child[]): CardNode { + const kept = (children.flat(4) as Child[]).filter((c): c is CardNode | string => c !== null && c !== undefined && c !== false && c !== ''); + return { type, props: { style: { display: 'flex', ...style }, children: kept.length === 1 ? kept[0] : kept } }; +} + +function img(src: string, width: number, height: number, style: Style = {}): CardNode { + return { type: 'img', props: { src, width, height, style: { width, height, ...style } } }; +} + +/** + * Support copy as satori lays out mixed weights: one element per word in a wrapping row (satori + * does not flow spans inline), "**stressed**" words brighter. + */ +function richText(text: string, size: number, color: string): CardNode[] { + // Words in order; `glued` marks one that follows the last with no space ("**code**."). + const words: { w: string; strong: boolean; glued: boolean }[] = []; + let spaced = true; + for (const part of String(text).split(/(\*\*[^*]+\*\*)/)) { + if (!part) continue; + const strong = part.startsWith('**') && part.endsWith('**'); + const body = strong ? part.slice(2, -2) : part; + for (const w of body.split(/(\s+)/)) { + if (!w) continue; + if (/^\s+$/.test(w)) { + spaced = true; + continue; + } + words.push({ w, strong, glued: !spaced && words.length > 0 }); + spaced = false; + } + } + const groups: (typeof words)[] = []; + for (const word of words) { + if (word.glued) groups[groups.length - 1].push(word); + else groups.push([word]); + } + const space = size * 0.27; + const span = ({ w, strong }: { w: string; strong: boolean }) => el('div', strong ? { color: STRONG, fontWeight: 600 } : { color }, w); + return groups.map((g, i) => el('div', { marginRight: i < groups.length - 1 ? space : 0 }, g.map(span))); +} + +/** + * The trail after the mark: capitals 0.2em apart, one space between words and two either side of + * each dot. Each word is its own element, since satori spaces a run of mono capitals unevenly. + * + * satori 0.32 (the Worker's) also spaces a run's last letter from what follows, which 0.33 (the + * spec's) does not: each word's margin takes that back, so the trail sits where the spec's does. + */ +const TRAIL_TRACKING = 5.6; +function eyebrow(parts: string[]): CardNode { + const space = 28 * 0.6 + TRAIL_TRACKING; + const items: { t: string; gap: number }[] = []; + parts.filter(Boolean).forEach((part, p) => { + if (p) items.push({ t: '·', gap: space * 2 }); + const words = String(part).toUpperCase().split(/\s+/).filter(Boolean); + words.forEach((w, i) => items.push({ t: w, gap: i < words.length - 1 ? space : space * 2 })); + }); + return el( + 'div', + { alignItems: 'center', fontFamily: MONO, fontSize: 28, fontWeight: 500, letterSpacing: TRAIL_TRACKING, color: MUTE, lineHeight: 1 }, + items.map((it, i) => el('div', { marginRight: (i < items.length - 1 ? it.gap : 0) - TRAIL_TRACKING }, it.t)) + ); +} + +function markWidth(mark: CardImage | null | undefined): number { + return mark ? Math.min(MARK_MAX_WIDTH, Math.round(((MARK_HEIGHT * mark.width) / mark.height) * 100) / 100) : 0; +} + +function lockup(parts: string[], art: LeftArt): CardNode { + const w = markWidth(art.mark); + return el('div', { alignItems: 'center', height: MARK_HEIGHT }, art.mark ? img(art.mark.src, w, MARK_HEIGHT, { marginRight: 18, objectFit: 'contain' }) : null, eyebrow(parts)); +} + +function titleBlock(card: LeftCard, title: string, size: number, art: LeftArt): CardNode { + const line = (text: string, color: string) => + el( + 'div', + { + width: card.avatar || art.portrait ? INNER - 170 : INNER, + fontSize: size, + fontWeight: 800, + lineHeight: TITLE_LINE, + letterSpacing: -0.03 * size, + color, + ...(card.balance ? { textWrap: 'balance' } : {}) + }, + text + ); + return el('div', { flexDirection: 'column', marginTop: 30 }, line(title, INK), card.dim ? line(card.dim, DIM) : null); +} + +function textBlock(text: string, size: number): CardNode { + return el( + 'div', + { flexWrap: 'wrap', marginTop: 24, maxWidth: 1040, fontSize: size, fontWeight: 500, lineHeight: TEXT_LINE, letterSpacing: -0.01 * size, color: SOFT }, + richText(text, size, SOFT) + ); +} + +function meta([left, right]: [string, string]): CardNode { + return el( + 'div', + { borderTop: `1px solid ${RULE}`, paddingTop: 30, width: INNER, justifyContent: 'space-between', alignItems: 'baseline' }, + el('div', { fontSize: 28, fontWeight: 500, color: LABEL, whiteSpace: 'nowrap' }, left || ''), + el('div', { fontFamily: MONO, fontSize: 28, fontWeight: 500, color: MUTE, whiteSpace: 'nowrap' }, right || '') + ); +} + +function avatar(card: LeftCard, art: LeftArt): CardNode | null { + const box = { position: 'absolute', top: PAD, right: PAD, width: AVATAR, height: AVATAR, borderRadius: AVATAR }; + if (art.portrait) return img(art.portrait.src, AVATAR, AVATAR, { ...box, objectFit: 'cover', border: '1px solid rgba(255,255,255,0.16)' }); + if (!card.avatar) return null; + return el( + 'div', + { ...box, border: '1px solid rgba(255,255,255,0.16)', backgroundColor: '#1a1a1a', color: STRONG, alignItems: 'center', justifyContent: 'center', fontSize: 52, fontWeight: 600, letterSpacing: -1 }, + card.avatar + ); +} + +/** One card's tree. `fit` is what fitCard() chose; without it the card is drawn at the design sizes. */ +export function cardTree(card: LeftCard, art: LeftArt, fit: LeftFit = {}): CardNode { + const text = fit.text === undefined ? card.text : fit.text; + return el( + 'div', + { position: 'relative', width: CARD_WIDTH, height: CARD_HEIGHT, backgroundColor: '#000', fontFamily: SANS, color: INK, overflow: 'hidden' }, + img(art.sky, CARD_WIDTH, CARD_HEIGHT, { position: 'absolute', top: 0, left: 0 }), + el( + 'div', + { position: 'absolute', top: 0, left: 0, width: CARD_WIDTH, height: CARD_HEIGHT, padding: PAD, flexDirection: 'column' }, + lockup(fit.eyebrow ?? card.eyebrow, art), + titleBlock(card, fit.title ?? card.title, fit.titleSize ?? TITLE_SIZES[0], art), + text ? textBlock(text, fit.textSize ?? TEXT_SIZE) : null, + el('div', { flexGrow: 1 }), + meta(fit.meta ?? card.meta) + ), + avatar(card, art) + ); +} + +// ------------------------------------------------------------------ fitting + +/** Satori's layout call, as fitCard uses it (the module's default export). */ +export type Layout = (node: CardNode, options: any) => Promise<string>; + +/** The footer band's height (a meta line), so the body knows its room. */ +const FOOTER_HEIGHT = 31 + 34; + +function sentences(text: string): string[] { + // A sentence ends at . ! or ? before a space, so "acme.io" and "3.0" stay whole. + return String(text) + .split(/(?<=[.!?](?:\*\*)?)\s+/) + .map(s => s.trim()) + .filter(Boolean); +} + +/** Height satori gives `node` laid out INNER wide. */ +async function heightOf(satori: Layout, fonts: any[], node: CardNode): Promise<number> { + const svg = await satori(el('div', { width: INNER, flexDirection: 'column', fontFamily: SANS }, node), { width: INNER, fonts }); + return Number(svg.match(/height="([\d.]+)"/)?.[1] ?? 0); +} + +async function widthOf(satori: Layout, fonts: any[], node: CardNode): Promise<number> { + const svg = await satori(el('div', { flexDirection: 'row', fontFamily: SANS }, node), { height: 200, fonts }); + return Number(svg.match(/width="([\d.]+)"/)?.[1] ?? 0); +} + +/** + * Fits a card's text to the frame with satori's own measurements. The title steps down from 64px + * until it takes two lines (three at 48px). The support copy keeps as many whole sentences as fit + * in three lines (two under a three-line title) at 31px; a single sentence too long for that goes + * to 28px, then ends at a clause or a word with an ellipsis. The address on the right of the meta + * line stays whole; the line on the left gives way to it when both don't fit. + * + * Beyond the spec, for any site's text: a trail too wide for the card drops its middle parts, then + * shortens its last one; a title still over three lines at 48px ends at a word with an ellipsis; + * and support copy with no room for one line is left out. + */ +export async function fitCard(card: LeftCard, art: LeftArt, satori: Layout, fonts: any[]): Promise<LeftFit> { + const room = CARD_HEIGHT - PAD * 2 - MARK_HEIGHT - FOOTER_HEIGHT - 8; + const fit: LeftFit = {}; + + // The trail: as wide as the card, less the mark and (top right) the avatar. + const trailRoom = INNER - (art.mark ? markWidth(art.mark) + 18 : 0) - (card.avatar || art.portrait ? 170 : 0); + const trailWidth = (parts: string[]) => widthOf(satori, fonts, eyebrow(parts)); + if ((await trailWidth(card.eyebrow)) > trailRoom) { + let parts = [...card.eyebrow]; + while (parts.length > 2 && (await trailWidth(parts)) > trailRoom) parts.splice(1, 1); + while ((await trailWidth(parts)) > trailRoom) { + const last = parts[parts.length - 1].replace(/…$/, ''); + const words = last.split(/\s+/); + if (words.length > 1) parts[parts.length - 1] = `${words.slice(0, -1).join(' ')}…`; + else if (parts.length > 1) parts = parts.slice(0, -1); + else break; + } + fit.eyebrow = parts; + } + + let titleSize = TITLE_SIZES[0]; + let titleHeight = 0; + let titleLines = 0; + const linesOf = (height: number, size: number) => Math.round((height - 30) / (size * TITLE_LINE)); + for (const size of TITLE_SIZES) { + titleSize = size; + titleHeight = await heightOf(satori, fonts, titleBlock(card, card.title, size, art)); + titleLines = linesOf(titleHeight, size); + if (titleLines <= (card.dim ? 1 : 0) + (size === TITLE_SIZES[TITLE_SIZES.length - 1] ? 3 : 2)) break; + } + fit.titleSize = titleSize; + const maxTitleLines = (card.dim ? 1 : 0) + 3; + if (titleLines > maxTitleLines) { + const words = card.title.split(' '); + for (let n = words.length - 1; n > 0; n--) { + const title = `${words.slice(0, n).join(' ').replace(/[,;:–—-]+$/, '')}…`; + const height = await heightOf(satori, fonts, titleBlock(card, title, titleSize, art)); + if (linesOf(height, titleSize) <= maxTitleLines || n === 1) { + fit.title = title; + titleHeight = height; + titleLines = linesOf(height, titleSize); + break; + } + } + } + + if (card.text) { + const maxLines = titleLines >= 3 ? 2 : 3; + const left = room - titleHeight; + const fits = async (text: string, size: number) => { + const h = await heightOf(satori, fonts, textBlock(text, size)); + return h <= left && Math.round((h - 24) / (size * TEXT_LINE)) <= maxLines; + }; + const parts = sentences(card.text); + let text: string | null = null; + for (let n = parts.length; n > 0 && !text; n--) if (await fits(parts.slice(0, n).join(' '), TEXT_SIZE)) text = parts.slice(0, n).join(' '); + fit.textSize = TEXT_SIZE; + if (!text && (await fits(parts[0], MIN_FONT))) { + text = parts[0]; + fit.textSize = MIN_FONT; + } + if (!text) { + // One sentence too long for the card: end it at the last clause that fits, else at a word. + fit.textSize = MIN_FONT; + const words = parts[0].split(' '); + let cut: string | null = null; + for (let n = words.length - 1; n > 3 && !cut; n--) { + const head = words.slice(0, n).join(' '); + if (/[,;:]$/.test(head) && (await fits(`${head.slice(0, -1)}…`, MIN_FONT))) cut = `${head.slice(0, -1)}…`; + } + for (let n = words.length - 1; n > 3 && !cut; n--) { + const t = `${words.slice(0, n).join(' ').replace(/[,;:–-]+$/, '')}…`; + if (await fits(t, MIN_FONT)) cut = t; + } + const four = `${words.slice(0, 4).join(' ')}…`; + // No room for even a line: the card goes without. + text = cut ?? ((await fits(four, MIN_FONT)) ? four : null); + } + fit.text = text; + } + + const [l, r] = card.meta; + const right = r ? await widthOf(satori, fonts, el('div', { fontFamily: MONO, fontSize: 28, fontWeight: 500 }, r)) : 0; + const fitsLeft = !!l && (await widthOf(satori, fonts, el('div', { fontSize: 28, fontWeight: 500 }, l))) <= INNER - right - 40; + fit.meta = [fitsLeft ? l : '', r]; + return fit; +} + +/** Every problem with a tree: any text under MIN_FONT. Empty when the card is fine. */ +export function checkCard(tree: CardNode): string[] { + const errors: string[] = []; + const walk = (node: CardNode | string | undefined, size: number) => { + if (!node || typeof node !== 'object') return; + const s = Number(node.props?.style?.fontSize ?? size); + const kids = node.props?.children; + const list = Array.isArray(kids) ? kids : [kids]; + if (list.some(k => typeof k === 'string' && k.trim()) && s < MIN_FONT) errors.push(`"${list.filter(k => typeof k === 'string').join(' ').slice(0, 40)}" is ${s}px (min ${MIN_FONT}px)`); + for (const k of list) walk(k as CardNode | string | undefined, s); + }; + walk(tree, 16); + return errors; +} diff --git a/packages/server/src/cards.ts b/packages/server/src/cards.ts index f887cf9..4f92804 100644 --- a/packages/server/src/cards.ts +++ b/packages/server/src/cards.ts @@ -4,8 +4,12 @@ * stored for good; a card's address changes whenever what it shows does. * * Satori lays the card out and resvg paints it, both WebAssembly bundled with - * the Worker and loaded only here. Inter comes from a font CDN once and stays - * in R2, as do the site's sky, logo and portraits once sized for the card. + * the Worker and loaded only here. The fonts (Inter, and JetBrains Mono for the + * left design) come from a font CDN once and stay in R2, as do the site's sky, + * logo and portraits once sized for the card. + * + * Two designs: centered (card-layout.ts, the default) and left (card-left.ts), + * picked by the site's appearance.shareCards.design. */ import type { ShareCard, ShareCards, ShareCardSite } from '@masthead/core'; import { shortHash } from '@masthead/render'; @@ -13,6 +17,7 @@ import { initWasm, Resvg } from '@resvg/resvg-wasm'; import resvgWasm from '@resvg/resvg-wasm/index_bg.wasm'; import satori, { init as initSatori } from 'satori/standalone'; import yogaWasm from 'satori/yoga.wasm'; +import { fitCard, LEFT_FONTS, type LeftCard, leftCard, cardTree as leftTree, skySvg, svgDataUrl } from './card-left'; import { CARD_DESIGN, CARD_HEIGHT, CARD_WIDTH, type CardImage, cardTree, drawable, skyTree, subsetsFor } from './card-layout'; import type { Ctx, Env } from './env'; import { imageSize } from './images'; @@ -21,8 +26,9 @@ import { SITE_PREFIX } from './publish'; /** R2 prefix for what cards are made from: fonts, the sky, sized images. Never served. */ const PARTS = 'cards/'; -/** Inter, as the Latin, Cyrillic, Greek and Vietnamese subsets fontsource publishes (WOFF: satori reads no WOFF2). */ -const FONT_CDN = 'https://cdn.jsdelivr.net/npm/@fontsource/inter@5.3.0/files/'; +/** Fonts as the Latin, Cyrillic, Greek and Vietnamese subsets fontsource publishes (WOFF: satori reads no WOFF2). */ +const FONT_CDN = 'https://cdn.jsdelivr.net/npm/@fontsource/'; +const INTER = { pkg: 'inter', version: '5.3.0' } as const; const WEIGHTS = [300, 400, 600] as const; const memo = new Map<string, Promise<unknown>>(); @@ -44,14 +50,17 @@ export async function drawCard(ctx: Ctx, key: string): Promise<Uint8Array | null /** One card as a PNG, stored nowhere (the editor's preview uses it as is). */ export async function paintCard(ctx: Ctx, raw: ShareCardSite, card: ShareCard): Promise<Uint8Array> { - const site: ShareCardSite = { ...raw, title: drawable(raw.title), wordmark: raw.wordmark && drawable(raw.wordmark) }; + const site: ShareCardSite = { ...raw, title: drawable(raw.title), wordmark: raw.wordmark && drawable(raw.wordmark), trail: raw.trail?.map(drawable).filter(Boolean) }; const clean: ShareCard = { ...card, title: drawable(card.title) || drawable(site.title), eyebrow: card.eyebrow && drawable(card.eyebrow), text: card.text && drawable(card.text), - meta: card.meta && drawable(card.meta) + meta: card.meta && drawable(card.meta), + dim: card.dim && drawable(card.dim), + address: card.address && drawable(card.address) }; + if (site.design === 'left') return paintLeft(ctx, site, leftCard(site, clean), clean.image); const [sky, logo, portrait, fonts] = await Promise.all([ skyFor(ctx, site.backdrop ?? null), site.logo ? picture(ctx, site.logo, { height: 192 }, 'image/png') : null, @@ -62,6 +71,26 @@ export async function paintCard(ctx: Ctx, raw: ShareCardSite, card: ShareCard): return paint(await satori(cardTree(site, clean, { sky, logo, portrait }), { width: CARD_WIDTH, height: CARD_HEIGHT, fonts })); } +/** A card in the left design: its text fitted with satori's measurements, then drawn on the still sky. */ +async function paintLeft(ctx: Ctx, site: ShareCardSite, card: LeftCard, image: string | null | undefined): Promise<Uint8Array> { + const [mark, portrait, fonts] = await Promise.all([ + site.logo ? picture(ctx, site.logo, { height: 192 }, 'image/png') : null, + image ? picture(ctx, image, { width: 264, height: 264, fit: 'cover' }, 'image/jpeg') : null, + leftFontsFor(ctx.env, [card.title, card.dim, card.text, ...card.eyebrow, ...card.meta]), + engine() + ]); + const art = { sky: leftSky(), mark, portrait }; + const fit = await fitCard(card, art, satori, fonts); + return paint(await satori(leftTree(card, art, fit), { width: CARD_WIDTH, height: CARD_HEIGHT, fonts })); +} + +let leftSkyUrl: string | null = null; +/** The left design's sky: one SVG, the same on every card. */ +function leftSky(): string { + leftSkyUrl ??= svgDataUrl(skySvg()); + return leftSkyUrl; +} + function paint(svg: string): Uint8Array { const resvg = new Resvg(svg, { fitTo: { mode: 'original' } }); const image = resvg.render(); @@ -181,16 +210,28 @@ async function fontsFor(env: Env, texts: (string | null | undefined)[]) { name: subset === 'latin' ? 'Inter' : `Inter ${subset}`, weight, style: 'normal' as const, - data: await font(env, `inter-${subset}-${weight}-normal.woff`) + data: await font(env, INTER, `inter-${subset}-${weight}-normal.woff`) })) ); } -function font(env: Env, file: string): Promise<ArrayBuffer> { +/** The left design's fonts: Inter in the subsets its text needs, and JetBrains Mono (Latin; other letters fall back to Inter). */ +async function leftFontsFor(env: Env, texts: (string | null | undefined)[]) { + const { sans, mono } = LEFT_FONTS; + const files = [ + ...subsetsFor(texts).flatMap(subset => sans.weights.map(weight => ({ f: sans, subset, weight, name: subset === 'latin' ? sans.family : `${sans.family} ${subset}` }))), + ...mono.weights.map(weight => ({ f: mono, subset: 'latin', weight, name: mono.family })) + ]; + return Promise.all( + files.map(async ({ f, subset, weight, name }) => ({ name, weight, style: 'normal' as const, data: await font(env, f, `${f.pkg}-${subset}-${weight}-normal.woff`) })) + ); +} + +function font(env: Env, from: { pkg: string; version: string }, file: string): Promise<ArrayBuffer> { return once(`${PARTS}fonts/${file}`, async key => { const kept = await env.BUCKET.get(key); if (kept) return kept.arrayBuffer(); - const res = await fetch(`${FONT_CDN}${file}`, { signal: AbortSignal.timeout(10_000) }); + const res = await fetch(`${FONT_CDN}${from.pkg}@${from.version}/files/${file}`, { signal: AbortSignal.timeout(10_000) }); if (!res.ok) throw new Error(`${file}: HTTP ${res.status}`); const bytes = await res.arrayBuffer(); await env.BUCKET.put(key, bytes, { httpMetadata: { contentType: 'font/woff' } }); From f8dd888caf51c00dcc84bdd50aa845da36be6373 Mon Sep 17 00:00:00 2001 From: "Pre.dev Agent" <agent@pre.dev> Date: Mon, 26 Oct 2026 14:15:11 -0400 Subject: [PATCH 2/2] fix(cards): leave a cut-off sentence off the left card's support line A post with no whole excerpt or description of its own gets its support line from the page's summary, which can end in an ellipsis where an import or the summary itself cut a sentence short. The card now keeps only the whole sentences before it, or shows no support line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/render/src/site.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/render/src/site.ts b/packages/render/src/site.ts index ad7c1a9..7257298 100644 --- a/packages/render/src/site.ts +++ b/packages/render/src/site.ts @@ -735,13 +735,13 @@ export function shownAddress(url: string): string { /** * A post's share card: its title, primary topic, date and byline (a page shows only its title). On the - * left design it also carries a support line (`summary`, the page's description, unless the post has - * whole share or excerpt text of its own) and the blog's address. + * left design it also carries a support line (the post's whole share, excerpt or meta description, else + * the whole sentences of `summary`, its description) and the blog's address. */ export function postShareCard(p: Post, tags: Tag[], authors: Author[], minutes: number, extra: { site?: SiteSettings; summary?: string | null } = {}): ShareCard { const post = p.type === 'post'; if (extra.site?.appearance?.shareCards?.design === 'left') { - const text = [p.ogDescription, p.customExcerpt, p.metaDescription].find(t => t?.trim() && !isTruncated(t)) || extra.summary || null; + const text = [p.ogDescription, p.customExcerpt, p.metaDescription].find(t => t?.trim() && !isTruncated(t)) || wholeSentences(extra.summary); return { kind: 'post', title: p.ogTitle || p.title, @@ -761,6 +761,15 @@ export function postShareCard(p: Post, tags: Tag[], authors: Author[], minutes: }; } +/** Text without the sentence a tool cut off ("...", "…"): the whole sentences before it, else none. */ +function wholeSentences(text: string | null | undefined): string | null { + const t = text?.replace(/\s+/g, ' ').trim(); + if (!t) return null; + if (!isTruncated(t)) return t; + const kept = t.split(/(?<=[.!?])\s+/).slice(0, -1).join(' '); + return kept || null; +} + /** The front page's share card: appearance.shareCards.home, else the masthead and the share description. */ function homeShareCard(site: SiteSettings, posts: number): ShareCard { const home = site.appearance?.shareCards?.home ?? {};