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/.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/.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 ``. | | `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..7257298 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 (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)) || wholeSentences(extra.summary); + 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,33 @@ 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 ?? {}; + 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' } });