diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7478885..39c4d86 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -74,6 +74,12 @@ export interface Post { ogImage?: string | null; ogTitle?: string | null; ogDescription?: string | null; + /** + * A looping MP4 for link previews (og:video) on a post, the size of its share + * image and under 10 MB for iMessage. Apps that play no video show the share + * image, so the video's first frame should be that image. + */ + ogVideo?: string | null; twitterImage?: string | null; twitterTitle?: string | null; twitterDescription?: string | null; diff --git a/packages/render/src/head.ts b/packages/render/src/head.ts index 8d44906..fd42ddd 100644 --- a/packages/render/src/head.ts +++ b/packages/render/src/head.ts @@ -11,6 +11,12 @@ export interface HeadInput { imageAlt?: string | null; /** The share image's size, when known (for og:image:width and og:image:height). */ imageSize?: { width: number; height: number } | null; + /** + * A looping MP4 the size of the share image (og:video), which iMessage and + * Discord play. It goes out only with a share image, which every other app + * shows instead. + */ + video?: string | null; /** Author names, shown as "Written by" in link previews on Slack and X. */ authors?: string[]; publishedAt?: string | null; @@ -33,10 +39,12 @@ export interface HeadInput { export function headTags(h: HeadInput): string { const handle = h.site.twitter ? (h.site.twitter.startsWith('@') ? h.site.twitter : `@${h.site.twitter}`) : null; const image = h.image ?? h.site.shareImage ?? null; + const video = image ? h.video : null; const tags: string[] = [ h.noindex ? '' : '', meta('property', 'og:site_name', h.site.title), - meta('property', 'og:type', h.type), + // Discord plays og:video only on a video type. + meta('property', 'og:type', video ? 'video.other' : h.type), meta('property', 'og:title', h.title), meta('property', 'og:description', h.description), meta('property', 'og:url', h.canonical), @@ -45,6 +53,11 @@ export function headTags(h: HeadInput): string { image && h.imageAlt ? meta('property', 'og:image:alt', h.imageAlt) : '', image && h.imageSize ? meta('property', 'og:image:width', String(h.imageSize.width)) : '', image && h.imageSize ? meta('property', 'og:image:height', String(h.imageSize.height)) : '', + video ? meta('property', 'og:video', video) : '', + video ? meta('property', 'og:video:secure_url', video) : '', + video ? meta('property', 'og:video:type', 'video/mp4') : '', + video && h.imageSize ? meta('property', 'og:video:width', String(h.imageSize.width)) : '', + video && h.imageSize ? meta('property', 'og:video:height', String(h.imageSize.height)) : '', h.publishedAt ? meta('property', 'article:published_time', h.publishedAt) : '', h.updatedAt ? meta('property', 'article:modified_time', h.updatedAt) : '', ...(h.tags ?? []).map(t => meta('property', 'article:tag', t.name)), diff --git a/packages/render/src/site.ts b/packages/render/src/site.ts index 7257298..4274399 100644 --- a/packages/render/src/site.ts +++ b/packages/render/src/site.ts @@ -346,6 +346,8 @@ export async function* renderSite(snapshot: Snapshot, options: BuildOptions, bod image: absolute(shareImage) ?? card?.url, imageAlt: card ? card.alt : p.featureImageAlt, imageSize: card ? card.size : imageSizeOf(shareImage), + // Posts only: LinkedIn is served the page without it, as an article (public.ts). + video: p.type === 'post' ? absolute(p.ogVideo) : null, authors: p.type === 'post' ? authors.map(a => a.name) : undefined, publishedAt: p.type === 'post' ? p.publishedAt : null, updatedAt: p.type === 'post' ? p.updatedAt : null, diff --git a/packages/server/src/content.ts b/packages/server/src/content.ts index e076b19..589910e 100644 --- a/packages/server/src/content.ts +++ b/packages/server/src/content.ts @@ -100,6 +100,7 @@ interface PostRow { og_title: string | null; og_description: string | null; og_image: string | null; + og_video?: string | null; twitter_title: string | null; twitter_description: string | null; twitter_image: string | null; @@ -132,6 +133,7 @@ function toPost(r: PostRow, tags: string[], authors: string[]): Post { ogTitle: r.og_title, ogDescription: r.og_description, ogImage: r.og_image, + ogVideo: r.og_video ?? null, twitterTitle: r.twitter_title, twitterDescription: r.twitter_description, twitterImage: r.twitter_image, @@ -189,7 +191,7 @@ export async function listPosts(db: D1Database, q: PostQuery): Promise<{ items: db .prepare( `SELECT id, type, slug, title, status, body_format, NULL AS markdown, NULL AS html, custom_excerpt, feature_image, feature_image_alt, feature_image_caption, - meta_title, meta_description, og_title, og_description, og_image, twitter_title, twitter_description, twitter_image, canonical_url, + meta_title, meta_description, og_title, og_description, og_image, og_video, twitter_title, twitter_description, twitter_image, canonical_url, featured, newsletter, published_at, created_at, updated_at, target_date FROM posts ${clause} ORDER BY CASE status WHEN 'draft' THEN 0 WHEN 'scheduled' THEN 1 ELSE 2 END, COALESCE(published_at, updated_at) DESC LIMIT ? OFFSET ?` ) @@ -233,6 +235,7 @@ const POST_FIELDS: [keyof Post, string][] = [ ['ogTitle', 'og_title'], ['ogDescription', 'og_description'], ['ogImage', 'og_image'], + ['ogVideo', 'og_video'], ['twitterTitle', 'twitter_title'], ['twitterDescription', 'twitter_description'], ['twitterImage', 'twitter_image'], @@ -484,7 +487,7 @@ export async function deleteStaff(db: D1Database, id: string): Promise { /** Every column but the bodies, for a snapshot that loads bodies on demand. */ const POST_META_COLUMNS = - 'id, type, slug, title, status, body_format, NULL AS markdown, NULL AS html, custom_excerpt, feature_image, feature_image_alt, feature_image_caption, meta_title, meta_description, og_title, og_description, og_image, twitter_title, twitter_description, twitter_image, canonical_url, featured, newsletter, published_at, created_at, updated_at'; + 'id, type, slug, title, status, body_format, NULL AS markdown, NULL AS html, custom_excerpt, feature_image, feature_image_alt, feature_image_caption, meta_title, meta_description, og_title, og_description, og_image, og_video, twitter_title, twitter_description, twitter_image, canonical_url, featured, newsletter, published_at, created_at, updated_at'; /** * Everything the site is built from. With `bodies: false` the posts come diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts index 676d7ff..8a2bfd4 100644 --- a/packages/server/src/db.ts +++ b/packages/server/src/db.ts @@ -185,7 +185,9 @@ const MIGRATIONS: string[][] = [ `ALTER TABLE staff ADD COLUMN meta_description TEXT`, `ALTER TABLE staff ADD COLUMN og_image TEXT`, `ALTER TABLE staff ADD COLUMN noindex INTEGER NOT NULL DEFAULT 0` - ] + ], + // v13: a post's share video (og:video), for the link previews that play one. + [`ALTER TABLE posts ADD COLUMN og_video TEXT`] ]; let migrated: Promise | null = null; diff --git a/packages/server/src/importer.ts b/packages/server/src/importer.ts index f18a6a6..a1aa40c 100644 --- a/packages/server/src/importer.ts +++ b/packages/server/src/importer.ts @@ -148,7 +148,7 @@ export async function importMedia(ctx: Ctx, items: { url: string; path: string } export async function rewriteUrls(ctx: Ctx, from: string, to: string) { if (!from || from.length < 8) throw new HttpError(400, 'from must be a URL prefix.'); const db = ctx.db; - const cols = ['markdown', 'html', 'feature_image', 'og_image', 'twitter_image']; + const cols = ['markdown', 'html', 'feature_image', 'og_image', 'og_video', 'twitter_image']; const results = await db.batch([ // instr, not LIKE: D1 rejects LIKE patterns as long as a storage URL. ...cols.map(c => db.prepare(`UPDATE posts SET ${c} = REPLACE(${c}, ?, ?) WHERE instr(${c}, ?) > 0`).bind(from, to, from)), diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index d02c124..eb35c56 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -221,6 +221,8 @@ function toPreview(res: Response, env: Env, base: string, at: string): Response .on('audio[src]', attr('src')) .on('form[action]', attr('action')) .on('meta[property="og:image"]', attr('content', true)) + .on('meta[property="og:video"]', attr('content', true)) + .on('meta[property="og:video:secure_url"]', attr('content', true)) .on('meta[name="twitter:image"]', attr('content', true)) .on('html', { element(el) { diff --git a/packages/server/src/public.ts b/packages/server/src/public.ts index 007df02..feaebbc 100644 --- a/packages/server/src/public.ts +++ b/packages/server/src/public.ts @@ -40,7 +40,27 @@ function legacyShape(path: string, base: string): string | null { return null; } +/** LinkedIn shows no image at all for a page with og:video, so its crawler gets the post without one, as the article it is. */ +const LINKEDIN = /LinkedInBot/i; + export async function serveSite(req: Request, ctx: Ctx): Promise { + const res = await siteFile(req, ctx); + if (!res.body || !LINKEDIN.test(req.headers.get('user-agent') ?? '') || !(res.headers.get('content-type') ?? '').startsWith('text/html')) return res; + return new HTMLRewriter() + .on('meta[property^="og:video"]', { + element(el) { + el.remove(); + } + }) + .on('meta[property="og:type"][content="video.other"]', { + element(el) { + el.setAttribute('content', 'article'); + } + }) + .transform(res); +} + +async function siteFile(req: Request, ctx: Ctx): Promise { const base = ctx.basePath; let path = decodeURIComponent(ctx.url.pathname); if (path === base.replace(/\/$/, '')) return redirect(base, 301);