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);