Repository navigation
feat(site): render a share image of /play's query - #134
Conversation
A shared /play?q=<sql> (or ?example=<slug>) link now previews its own query: a function serves /play's prerendered page with og:image and twitter:image naming /play/og.png with the same query, which a second function draws as a 1200x630 card, highlighted and cut with an ellipsis when it does not fit. Plain /play gets a static card of the example it opens with.
The analyze and schema functions carry all of .live/, so the share modules built there went into them too, with Satori and Shiki. They now build to .share/ from their own config.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
ℹ️ No blocking issues: three rough edges, noted inline.
Reviewed changes
I reviewed the whole PR: a share card for /play, the two Vercel functions that serve it, and the page's meta tags.
- Card renderer:
lib/live/share-card.tsxdraws a 1200×630 PNG of the query with@vercel/ogand Shiki. The layout first tries 46px down to 22px with no wrapping, then 46px down to 20px with wrapping, and otherwise cuts the query to 14 rows ending in an ellipsis. - Share helpers:
lib/live/share.tsreadsqorexamplefrom the address, builds the og and twitter tags, and rewrites the prerendered page's meta tags with every value escaped. - Shared example parsing:
inferenceStatementsmoves tolib/live/inference.ts, so the build and the share functions read03-inference.mdxthe same way. - Functions:
api/play-og/image.mjsandapi/play-og/page.mjsare bundled into.share/byvite.share.config.ts, apart from.live/.vercel.jsonroutes/play/og.png?q|exampleand/play?q|exampleto them, after the JSON analyze route. - Static card and
meta:/play/og.pngis prerendered from the opening example.play.tsx'smetacomputes the same tags in the browser, with a hash ofshare-card.tsxas the image's version.
claude-opus-5-5 | 𝕏
| const card = await playCard(shared.sql, fonts, { 'content-type': 'image/png', 'cache-control': KEPT, 'x-content-type-options': 'nosniff' }); | ||
| return request.method === 'HEAD' ? new Response(null, { status: card.status, headers: card.headers }) : card; |
There was a problem hiding this comment.
ImageResponse draws the image inside its body stream's start(), not in its constructor (in @vercel/og 0.11.1, start() awaits render(satori, resvg, …) and then calls controller.enqueue). So this try only catches failures in highlighting and font loading. If satori or resvg fails, the client gets a 200 with cache-control: s-maxage=31536000 and a body that stops partway. The 500 branch never runs for those failures. Reading the body inside the try brings them back into the catch:
| const card = await playCard(shared.sql, fonts, { 'content-type': 'image/png', 'cache-control': KEPT, 'x-content-type-options': 'nosniff' }); | |
| return request.method === 'HEAD' ? new Response(null, { status: card.status, headers: card.headers }) : card; | |
| const card = await playCard(shared.sql, fonts); | |
| const png = await card.arrayBuffer(); | |
| const headers = { 'content-type': 'image/png', 'cache-control': KEPT, 'x-content-type-options': 'nosniff' }; | |
| return new Response(request.method === 'HEAD' ? null : png, { headers }); |
There was a problem hiding this comment.
Applied on main in f266c66: the body is read inside the try, so a failure to draw is a 500 and never a cut-off PNG kept for a year.
| import source from '../../content/sections/03-inference.mdx?raw'; | ||
| import { inferenceStatements, START } from './inference.ts'; | ||
|
|
||
| export const examples: ReadonlyMap<string, string> = new Map(inferenceStatements(source).map((e) => [e.slug, e.sql])); |
There was a problem hiding this comment.
This map holds every Inference example. playData (lib/live/play-data.ts:36-47) drops examples that have diagnostics, or whose card's schema gives different types. For a dropped slug, /play?example=<slug> previews that statement, but the playground (lib/live/app.tsx:292-293) does not find it and opens the start example instead. The page's meta in the browser also lacks the slug, so it falls back to the default tags and no longer matches the head the function served. The share functions need the same list of examples as playData, for example a list of slugs written at build time, or the same filter applied here.
There was a problem hiding this comment.
Applied on main in f266c66: /play's loader writes the examples the page shows, and the share functions, built after it, preview only those; ?example= of a dropped one keeps the page's own card (checked with json-schema).
| const { playData } = await import('@/lib/live/play-data'); | ||
| return playData(); | ||
| const card = await readFile('lib/live/share-card.tsx'); | ||
| return { ...(await playData()), og: createHash('sha256').update(card).digest('hex').slice(0, 16) }; |
There was a problem hiding this comment.
The version hashes share-card.tsx alone. Changing share.ts (cardSize, CARD_TEXT_BUDGET), components/wordmark.tsx or the fonts redraws the card under the same ?v=, so X and Slack keep showing the old card. The same goes for editing the text of an example: the static /play/og.png?v=… and every ?example= card keep their addresses. Hashing those inputs as well, including the start example's SQL (or all of 03-inference.mdx), would make the comment's claim hold.
There was a problem hiding this comment.
Applied on main in f266c66: the version hashes share-card.tsx, share.ts, the wordmark, both fonts and the shown examples' text.
* feat(site): render a share image of /play's query A shared /play?q=<sql> (or ?example=<slug>) link now previews its own query: a function serves /play's prerendered page with og:image and twitter:image naming /play/og.png with the same query, which a second function draws as a 1200x630 card, highlighted and cut with an ellipsis when it does not fit. Plain /play gets a static card of the example it opens with. * fix(site): build the share functions apart from the playground's api The analyze and schema functions carry all of .live/, so the share modules built there went into them too, with Satori and Shiki. They now build to .share/ from their own config.

A link to
/play?q=<sql>(or?example=<slug>) now previews as a card of that query on X, Slack and the like: 1200×630, the query syntax-highlighted in the playground's colours under a bar with the wordmark, as large as fits, with lines wrapped or the end faded into…where it is long. Plain/playgets a static card of its opening example.site/lib/live/share-card.tsx, drawn with@vercel/og(satori), Shiki's SQL grammar and GitHub Dark colours, Geist and Geist Mono. Sized from 46px down to 22px with no line wrapped, else wrapped down to 20px, then truncated at 14 rows./play/og.png?q=…is a function (api/play-og/image.mjs) that loads only Shiki,@vercel/ogand the fonts, never PGlite or the engine; built apart from the playground's API into.share/./play?q=and/play?example=, when not asking for JSON, go toapi/play-og/page.mjs, which serves the prerendered page withog:url,og:image,og:image:alt,twitter:imageandtwitter:image:altrewritten in place, every value escaped. The JSON route for/play?q=stays first.app/routes/play.tsx'smetacomputes the same tags, so hydration keeps one set.Checked: every docs example and edge cases rendered and looked at (a one-liner, an 18-line query, a 120-line one, very long lines, Unicode, a 3000-character token); the handlers called with
Requests (rewritten tags, escaping of an injection attempt, a 404 for an unknown example, HEAD);vercel buildlocally and each function rebuilt from its output;nub run buildinsite/. Routing on a real deployment is checked on this PR's preview.