Skip to content

Commit c45d396

Browse files
committed
Use screenshots in social media cards
1 parent c248f28 commit c45d396

5 files changed

Lines changed: 182 additions & 0 deletions

File tree

apps/gif-service/src/hasura.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,53 @@ export async function fetchProject(
105105
return data.project[0] ?? null;
106106
}
107107

108+
export interface ProjectMeta {
109+
projectId: string;
110+
title: string;
111+
updatedAt: string;
112+
}
113+
114+
/** Public project meta from a /u/<userSlug>/<projectSlug> URL (public profile + public project). */
115+
export async function fetchProjectMetaBySlug(
116+
userSlug: string,
117+
projectSlug: string,
118+
): Promise<ProjectMeta | null> {
119+
const query = `
120+
query ($userSlug: String!, $projectSlug: String!) {
121+
project(where: { slug: { _eq: $projectSlug }, owner: { slug: { _eq: $userSlug } } }, limit: 1) {
122+
project_id
123+
title
124+
updated_at
125+
}
126+
}
127+
`;
128+
const data = await gql<{ project: Array<{ project_id: string; title: string; updated_at: string }> }>(
129+
query,
130+
{ userSlug, projectSlug },
131+
);
132+
const p = data.project[0];
133+
return p ? { projectId: p.project_id, title: p.title, updatedAt: p.updated_at } : null;
134+
}
135+
136+
/** Public project meta by id (for /projects/<id> URLs). */
137+
export async function fetchProjectMetaById(id: string): Promise<ProjectMeta | null> {
138+
const query = `
139+
query ($id: uuid!) {
140+
project(where: { project_id: { _eq: $id } }, limit: 1) {
141+
project_id
142+
title
143+
updated_at
144+
}
145+
}
146+
`;
147+
const data = await gql<{ project: Array<{ project_id: string; title: string; updated_at: string }> }>(
148+
query,
149+
{ id },
150+
);
151+
const p = data.project[0];
152+
return p ? { projectId: p.project_id, title: p.title, updatedAt: p.updated_at } : null;
153+
}
154+
108155
/**
109156
* Compile ZX BASIC (Boriel) or C (z88dk) through the Hasura actions, returning
110157
* the TAP bytes. A rejection here usually means the source did not compile, so

apps/gif-service/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import basicRoutes from './routes/basic.js';
44
import projectRoutes from './routes/project.js';
55
import sourceRoutes from './routes/source.js';
66
import screenshotRoutes from './routes/screenshot.js';
7+
import ogRoutes from './routes/og.js';
78

89
// Safety net: a single bad render must never take down the whole service. Log
910
// and keep serving rather than letting an unhandled error exit the process
@@ -22,6 +23,9 @@ app.use('/api', projectRoutes);
2223
app.use('/api', sourceRoutes);
2324
// Public, read-only screenshots (only this path is exposed via the proxy).
2425
app.use('/screenshots', screenshotRoutes);
26+
// OpenGraph cards for /u/* and /projects/* — the proxy routes only social
27+
// crawlers here; real users get the SPA.
28+
app.use(ogRoutes);
2529

2630
app.get('/health', (req, res) => {
2731
res.json({ status: 'ok' });

apps/gif-service/src/routes/og.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { Router, Request, Response } from 'express';
2+
import { fetchProjectMetaBySlug, fetchProjectMetaById, ProjectMeta } from '../hasura.js';
3+
4+
// OpenGraph cards for shared project links. Caddy routes only social-crawler
5+
// requests for /u/* and /projects/* here; real users fall through to the SPA.
6+
// So we serve a small purpose-built page with the project's card metadata
7+
// (og:image -> the rendered screenshot) — crawlers parse the tags, they don't
8+
// run the app.
9+
const router = Router();
10+
11+
const PUBLIC_ORIGIN = (process.env.PUBLIC_ORIGIN ?? 'https://code.zxplay.org').replace(/\/$/, '');
12+
const SLUG = /^[a-z0-9-]+$/i;
13+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
14+
15+
function esc(s: string): string {
16+
return s
17+
.replace(/&/g, '&amp;')
18+
.replace(/"/g, '&quot;')
19+
.replace(/</g, '&lt;')
20+
.replace(/>/g, '&gt;');
21+
}
22+
23+
const SITE_TITLE = 'Code · ZX Play';
24+
const SITE_DESC = 'A ZX Spectrum emulator & programming environment for the browser.';
25+
const SITE_IMAGE = `${PUBLIC_ORIGIN}/assets/images/embed-preview.png`;
26+
27+
function ogPage(opts: { title: string; description: string; image: string; url: string }): string {
28+
const title = esc(opts.title);
29+
const description = esc(opts.description);
30+
const image = esc(opts.image);
31+
const url = esc(opts.url);
32+
return `<!doctype html>
33+
<html lang="en">
34+
<head>
35+
<meta charset="utf-8">
36+
<title>${title}</title>
37+
<meta property="og:type" content="website">
38+
<meta property="og:title" content="${title}">
39+
<meta property="og:description" content="${description}">
40+
<meta property="og:image" content="${image}">
41+
<meta property="og:url" content="${url}">
42+
<meta name="twitter:card" content="summary_large_image">
43+
<meta name="twitter:title" content="${title}">
44+
<meta name="twitter:description" content="${description}">
45+
<meta name="twitter:image" content="${image}">
46+
</head>
47+
<body><p><a href="${esc(opts.url || PUBLIC_ORIGIN)}">View on ZX Play</a></p></body>
48+
</html>`;
49+
}
50+
51+
function projectCard(meta: ProjectMeta, canonicalUrl: string): string {
52+
const version = Date.parse(meta.updatedAt) || 0;
53+
return ogPage({
54+
title: `${meta.title} · ZX Play`,
55+
description: `${meta.title} — a ZX Spectrum program on ZX Play.`,
56+
image: `${PUBLIC_ORIGIN}/screenshots/${meta.projectId}.png?v=${version}`,
57+
url: canonicalUrl,
58+
});
59+
}
60+
61+
const genericCard = (url: string): string =>
62+
ogPage({ title: SITE_TITLE, description: SITE_DESC, image: SITE_IMAGE, url: url || PUBLIC_ORIGIN });
63+
64+
async function send(res: Response, html: string): Promise<void> {
65+
// Short cache: a crawler re-fetch picks up edits / new screenshots reasonably soon.
66+
res.setHeader('Cache-Control', 'public, max-age=300');
67+
res.type('html').send(html);
68+
}
69+
70+
router.get('/u/:user/:project', async (req: Request, res: Response) => {
71+
const user = (req.params.user || '').toLowerCase();
72+
const project = (req.params.project || '').toLowerCase();
73+
const canonical = `${PUBLIC_ORIGIN}/u/${req.params.user}/${req.params.project}`;
74+
if (SLUG.test(user) && SLUG.test(project)) {
75+
try {
76+
const meta = await fetchProjectMetaBySlug(user, project);
77+
if (meta) {
78+
await send(res, projectCard(meta, canonical));
79+
return;
80+
}
81+
} catch (err) {
82+
console.error('OG slug lookup failed:', err);
83+
}
84+
}
85+
await send(res, genericCard(canonical));
86+
});
87+
88+
router.get('/projects/:id', async (req: Request, res: Response) => {
89+
const id = (req.params.id || '').toLowerCase();
90+
const canonical = `${PUBLIC_ORIGIN}/projects/${req.params.id}`;
91+
if (UUID.test(id)) {
92+
try {
93+
const meta = await fetchProjectMetaById(id);
94+
if (meta) {
95+
await send(res, projectCard(meta, canonical));
96+
return;
97+
}
98+
} catch (err) {
99+
console.error('OG id lookup failed:', err);
100+
}
101+
}
102+
await send(res, genericCard(canonical));
103+
});
104+
105+
// Other crawler hits under these prefixes (profiles, lists) → generic site card.
106+
router.get(['/u', '/u/*', '/projects', '/projects/*'], async (_req: Request, res: Response) => {
107+
await send(res, genericCard(''));
108+
});
109+
110+
export default router;

apps/proxy/Caddyfile

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@
3939
reverse_proxy localhost:5001
4040
}
4141

42+
# OpenGraph cards for shared project links: social crawlers only; browsers
43+
# fall through to the SPA.
44+
@ogcrawler {
45+
path /u/* /projects/*
46+
header_regexp User-Agent (?i)(Mastodon|gotosocial|Pleroma|Akkoma|Misskey|Twitterbot|facebookexternalhit|Facebot|Discordbot|Slackbot|TelegramBot|WhatsApp|bsky|LinkedInBot|redditbot|Iframely|Embedly|Synapse|SkypeUriPreview|vkShare|Google-PageRenderer)
47+
}
48+
handle @ogcrawler {
49+
reverse_proxy localhost:5001
50+
}
51+
4252
handle {
4353
reverse_proxy localhost:8000
4454
}

apps/proxy/prod.Caddyfile

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,17 @@
3535
reverse_proxy gif-service:5001
3636
}
3737

38+
# OpenGraph cards for shared project links: only social-media crawlers
39+
# fetching a project URL get the gif-service OG page (which points og:image
40+
# at the screenshot). Real browsers fall through to the SPA below.
41+
@ogcrawler {
42+
path /u/* /projects/*
43+
header_regexp User-Agent (?i)(Mastodon|gotosocial|Pleroma|Akkoma|Misskey|Twitterbot|facebookexternalhit|Facebot|Discordbot|Slackbot|TelegramBot|WhatsApp|bsky|LinkedInBot|redditbot|Iframely|Embedly|Synapse|SkypeUriPreview|vkShare|Google-PageRenderer)
44+
}
45+
handle @ogcrawler {
46+
reverse_proxy gif-service:5001
47+
}
48+
3849
handle {
3950
# Strict Content Security Policy for production
4051
header {

0 commit comments

Comments
 (0)