Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 14 additions & 1 deletion packages/render/src/head.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 name="robots" content="noindex">' : '<meta name="robots" content="max-image-preview:large, max-snippet:-1, max-video-preview:-1">',
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),
Expand All @@ -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)),
Expand Down
2 changes: 2 additions & 0 deletions packages/render/src/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions packages/server/src/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 ?`
)
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -484,7 +487,7 @@ export async function deleteStaff(db: D1Database, id: string): Promise<void> {

/** 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
Expand Down
4 changes: 3 additions & 1 deletion packages/server/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> | null = null;
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/importer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
2 changes: 2 additions & 0 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
20 changes: 20 additions & 0 deletions packages/server/src/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> {
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<Response> {
const base = ctx.basePath;
let path = decodeURIComponent(ctx.url.pathname);
if (path === base.replace(/\/$/, '')) return redirect(base, 301);
Expand Down
Loading