8000 feat(site): render a share image of /play's query by colinhacks · Pull Request #134 · colinhacks/reylo · GitHub
[go: up one dir, main page]

Skip to content

feat(site): render a share image of /play's query - #134

Merged
colinhacks merged 2 commits into
mainfrom
play-og
Oct 27, 2026
Merged

colinhacks merged 2 commits into
mainfrom
play-og

Conversation

@colinhacks
Copy link
Copy Markdown
Owner

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 /play gets a static card of its opening example.

  • The card: 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.
  • The image: /play/og.png?q=… is a function (api/play-og/image.mjs) that loads only Shiki, @vercel/og and the fonts, never PGlite or the engine; built apart from the playground's API into .share/.
  • The page: /play?q= and /play?example=, when not asking for JSON, go to api/play-og/page.mjs, which serves the prerendered page with og:url, og:image, og:image:alt, twitter:image and twitter:image:alt rewritten in place, every value escaped. The JSON route for /play?q= stays first. app/routes/play.tsx's meta computes the same tags, so hydration keeps one set.
  • Both answers depend only on the URL and are cached at the CDN; the image URL carries a hash of the card's code, so a new card design gets new URLs.

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 build locally and each function rebuilt from its output; nub run build in site/. Routing on a real deployment is checked on this PR's preview.

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.
Copilot AI balanced review requested due to automatic review settings October 27, 2026 15:26
@vercel
vercel Bot commented Oct 27, 2026 •
Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
reylo Ready Ready Preview Oct 27, 2026 3:29pm UTC

Request Review

Copilot AI left a comment
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@pullfrog pullfrog Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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.tsx draws a 1200×630 PNG of the query with @vercel/og and 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.ts reads q or example from the address, builds the og and twitter tags, and rewrites the prerendered page's meta tags with every value escaped.
  • Shared example parsing: inferenceStatements moves to lib/live/inference.ts, so the build and the share functions read 03-inference.mdx the same way.
  • Functions: api/play-og/image.mjs and api/play-og/page.mjs are bundled into .share/ by vite.share.config.ts, apart from .live/. vercel.json routes /play/og.png?q|example and /play?q|example to them, after the JSON analyze route.
  • Static card and meta: /play/og.png is prerendered from the opening example. play.tsx's meta computes the same tags in the browser, with a hash of share-card.tsx as the image's version.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using claude-opus-5-5 | 𝕏

Comment on lines +16 to +17
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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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 });

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread site/app/routes/play.tsx
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) };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied on main in f266c66: the version hashes share-card.tsx, share.ts, the wordmark, both fonts and the shown examples' text.

@colinhacks
colinhacks merged commit 6faecde into main Oct 27, 2026
7 checks passed
@colinhacks
colinhacks deleted the play-og branch October 27, 2026 15:37
colinhacks added a commit that referenced this pull request Oct 27, 2026
* 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

0