diff --git a/website/blog/2026-10-03-v0.19-batch-set.md b/website/blog/2026-10-03-v0.19-batch-set.md
index 8205487a01d0..b02245c14ba9 100644
--- a/website/blog/2026-10-03-v0.19-batch-set.md
+++ b/website/blog/2026-10-03-v0.19-batch-set.md
@@ -1,6 +1,7 @@
---
title: 'v0.19: Batch Controller.set(), Faster TypeScript'
description: Write many entities in one store update with Controller.set([Entity], rows), and type-check endpoint code about 2x faster
+image: /img/social/0.19-card.png
authors: [ntucker]
tags: [releases, managers, schema]
draft: true
diff --git a/website/package.json b/website/package.json
index 56f4e8cfa6e9..c3dfda90f950 100644
--- a/website/package.json
+++ b/website/package.json
@@ -16,7 +16,8 @@
"swizzle": "docusaurus swizzle",
"deploy": "USE_SSH=true docusaurus deploy",
"docusaurus": "docusaurus",
- "build:skills": "node framework-docs/skillReferences.mjs"
+ "build:skills": "node framework-docs/skillReferences.mjs",
+ "social-card": "node scripts/socialCard.mjs"
},
"engines": {
"node": ">=18.0"
@@ -36,6 +37,7 @@
"@types/uuid": "^11.0.0",
"esbuild": "~0.28.0",
"mdast-util-phrasing": "^4.0.0",
+ "mermaid": ">=11.6.0",
"playwright": "1.61.1",
"raw-loader": "^4.0.2",
"react-redux": "^9.2.0",
diff --git a/website/scripts/socialCard.mjs b/website/scripts/socialCard.mjs
new file mode 100644
index 000000000000..57b74c132bae
--- /dev/null
+++ b/website/scripts/socialCard.mjs
@@ -0,0 +1,771 @@
+/**
+ * Renders a release blog post's social card to static/img/social/X.Y-card.png
+ * from the post's frontmatter and summary bullets.
+ *
+ * Usage (from repo root):
+ * yarn workspace rdc-website social-card blog/2026-10-03-v0.19-batch-set.md
+ * yarn workspace rdc-website social-card 0.19 --force # overwrite existing
+ *
+ * Cards before v0.19 are hand-made; don't --force over them.
+ *
+ * Requires a Chromium for Playwright (`npx playwright install chromium`), or
+ * set CHROMIUM_PATH to an existing Chromium binary.
+ */
+import {
+ createSlugger,
+ DEFAULT_PARSE_FRONT_MATTER,
+ parseMarkdownHeadingId,
+} from '@docusaurus/utils';
+import fs from 'node:fs';
+import { createRequire } from 'node:module';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { parseArgs } from 'node:util';
+import { chromium } from 'playwright';
+
+const require = createRequire(import.meta.url);
+const WEBSITE_ROOT = path.join(
+ path.dirname(fileURLToPath(import.meta.url)),
+ '..',
+);
+const BLOG_DIR = path.join(WEBSITE_ROOT, 'blog');
+const OUT_DIR = path.join(WEBSITE_ROOT, 'static/img/social');
+// Same size as the hand-made v0.14–v0.18 cards (≈1.91:1 Open Graph ratio)
+const WIDTH = 1731;
+const HEIGHT = 909;
+const MAX_ROWS = 4;
+
+function findPost(arg) {
+ if (!arg) throw new Error('Pass a blog post path or a version like 0.19');
+ if (fs.existsSync(arg)) return arg;
+ const inBlog = path.join(WEBSITE_ROOT, arg);
+ if (fs.existsSync(inBlog)) return inBlog;
+ const version = arg.replace(/^v/, '');
+ const match = fs
+ .readdirSync(BLOG_DIR)
+ .find(f => f.includes(`-v${version}-`) && /\.mdx?$/.test(f));
+ if (!match) throw new Error(`No blog post found for v${version}`);
+ return path.join(BLOG_DIR, match);
+}
+
+async function parsePost(postFile) {
+ const { frontMatter, content: body } = await DEFAULT_PARSE_FRONT_MATTER({
+ filePath: postFile,
+ fileContent: fs.readFileSync(postFile, 'utf8').replace(/\r\n/g, '\n'),
+ });
+ const { title, description = '', image } = frontMatter;
+ const [, version, headline] = title.match(/^v(\d+\.\d+):?\s*(.*)$/);
+ const [summary, details = ''] = body.split(
+ /\{\/\*\s*truncate\s*\*\/\}|/,
+ );
+ const bullets = summaryBullets(summary);
+ return {
+ version,
+ headline,
+ description,
+ image,
+ rows: bullets
+ .slice(0, MAX_ROWS)
+ .map(({ tag, text }) => ({ tag, label: bulletLabel(text) })),
+ feature: featureVisual(headline, bullets, details),
+ };
+}
+
+// Bullets under **Bold section:** headings in the summary, new features first
+function summaryBullets(summary) {
+ const rows = [];
+ let tag;
+ for (const line of summary.split('\n')) {
+ const heading = line.match(/^\*\*(.+?):?\*\*:?\s*$/);
+ if (heading) {
+ tag =
+ /breaking/i.test(heading[1]) ? undefined
+ : /new/i.test(heading[1]) ? 'new'
+ : 'improved';
+ continue;
+ }
+ const bullet = line.match(/^[-*]\s+(.*)/);
+ if (tag && bullet) rows.push({ tag, text: bullet[1] });
+ }
+ return rows.sort((a, b) => (b.tag === 'new') - (a.tag === 'new'));
+}
+
+const MAX_CODE_LINES = 8;
+const MAX_CHART_ROWS = 4;
+// Put {/* card */} right before a chart, diagram, image or code block in a
+// feature section to pick what the card shows for that feature
+const CARD_MARKER = /\{\/\*\s*card\s*\*\/\}/g;
+const PERF = /fast|perf|speed|parallel/i;
+
+// One visual per headline feature (the title's comma-separated parts, else the
+// new bullets' sections): the marked one, else a chart for performance
+// features, else a diagram, image, code or chart, in that order
+function featureVisual(headline, bullets, details) {
+ const sections = headlineSections(headline, bullets, details);
+ if (!sections.length) return;
+ const speedupIn = text => text?.match(/(\d+(?:\.\d+)?)x faster/i)?.[1];
+ const visuals = sections
+ .map(({ title, id, section }) => {
+ const found = findVisuals(section);
+ const order =
+ PERF.test(title) ?
+ ['chart', 'diagram', 'image', 'code']
+ : ['diagram', 'image', 'code', 'chart'];
+ const pick =
+ found.find(v => v.marked) ??
+ order.map(k => found.find(v => v.kind === k)).find(Boolean);
+ // the section's own claim, else the claim in the bullet linking to it
+ const speedup =
+ speedupIn(section) ??
+ speedupIn(bullets.find(b => id && b.text.includes(`#${id})`))?.text);
+ return pick && { ...pick, title, speedup };
+ })
+ .filter(Boolean);
+ if (!visuals.length) return;
+ const [first] = visuals;
+ // A lone feature with Before/After code shows both sides, with a big badge
+ const pair = visuals.length === 1 && first.before;
+ const panels =
+ pair ?
+ [
+ { kind: 'code', title: 'Before', code: first.before, diff: 'before' },
+ { ...first, title: 'After', diff: 'after', speedup: undefined },
+ ]
+ : visuals;
+ return { panels, speedup: pair && first.speedup };
+}
+
+// Sections for the title's comma-separated parts (by shared heading words),
+// then the sections the new bullets link to; else the first section with a visual
+function headlineSections(headline, bullets, details) {
+ // anchors as Docusaurus makes them: one slugger over every heading in order,
+ // so explicit {#id}s and repeated titles get the same -1 suffixes
+ const slugger = createSlugger();
+ const headings = [...details.matchAll(/^(#{2,6}) (.+)$/gm)]
+ .map(m => {
+ const { text, id } = parseMarkdownHeadingId(m[2]);
+ const title = text.replace(/`/g, '');
+ return {
+ level: m[1].length,
+ title,
+ id: id ? slugger.slug(id, { maintainCase: true }) : slugger.slug(title),
+ index: m.index,
+ };
+ })
+ .filter(
+ h =>
+ h.level === 2 && !/migration|other improvements|upgrade/i.test(h.title),
+ );
+ const words = s =>
+ new Set(
+ s
+ .toLowerCase()
+ .split(/[^a-z0-9]+/)
+ .filter(w => w.length > 2)
+ .map(w => w.replace(/s$/, '')),
+ );
+ const byTitle = headline.split(/,\s*/).map(part => {
+ const partWords = [...words(part)];
+ let best,
+ bestScore = 0;
+ for (const h of headings) {
+ const hWords = words(h.title);
+ const score = partWords.filter(w => hWords.has(w)).length;
+ if (score > bestScore) [best, bestScore] = [h, score];
+ }
+ return best && { ...best, title: part };
+ });
+ const byBullet = bullets
+ .filter(b => b.tag === 'new')
+ .map(b => {
+ const anchor = b.text.match(/\]\([^)#]*#([\w-]+)\)/)?.[1];
+ const h = headings.find(h => h.id === anchor);
+ return h && { ...h, title: bulletLabel(b.text) };
+ });
+ const sectionAt = index => details.slice(index).split(/\n## /)[0];
+ const chosen = [...byTitle, ...byBullet].filter(
+ (h, i, all) => h && all.findIndex(o => o?.index === h.index) === i,
+ );
+ const picked =
+ chosen.length ? chosen : (
+ headings.filter(h => findVisuals(sectionAt(h.index)).length).slice(0, 1)
+ );
+ return picked
+ .slice(0, 3)
+ .map(({ title, id, index }) => ({ title, id, section: sectionAt(index) }));
+}
+
+// Every chart, diagram, image and code block in a section, in source order
+function findVisuals(section) {
+ const patterns = [
+ [/]*src=")(\/img\/[^)\s"]+)/g, imageVisual],
+ [/```(?!mermaid)\w*([^\n]*)\n([\s\S]*?)\n```/g, codeVisual],
+ ];
+ const visuals = patterns
+ .flatMap(([re, parse]) =>
+ [...section.matchAll(re)].map(m => {
+ const visual = parse(...m.slice(1));
+ return visual && { ...visual, index: m.index };
+ }),
+ )
+ .filter(Boolean);
+ // each {/* card */} marks the first visual after it
+ visuals.sort((a, b) => a.index - b.index);
+ for (const { index } of section.matchAll(CARD_MARKER)) {
+ const next = visuals.find(v => v.index > index);
+ if (next) next.marked = true;
+ }
+ const before = visuals.find(v => v.kind === 'code' && v.title === 'Before');
+ return visuals
+ .filter(v => v !== before)
+ .map(v =>
+ before && v.kind === 'code' && v.title === 'After' ?
+ { ...v, before: before.code }
+ : v,
+ );
+}
+
+function perfChart(props) {
+ const prop = name => props.match(new RegExp(`${name}="([^"]*)"`))?.[1];
+ const rows = [
+ ...props.matchAll(
+ /label:\s*'([^']*)',\s*baseline:\s*([\d.]+),\s*value:\s*([\d.]+)/g,
+ ),
+ ].map(([, label, baseline, value]) => ({
+ label,
+ baseline: +baseline,
+ value: +value,
+ }));
+ if (!rows.length) return;
+ return {
+ kind: 'chart',
+ chartTitle: prop('title'),
+ baselineLabel: prop('baselineLabel'),
+ valueLabel: prop('valueLabel'),
+ higherIsBetter: /higherIsBetter(?!=\{false\})/.test(props),
+ rows,
+ };
+}
+
+// xychart-beta "% of baseline" bars become a chart; other mermaid is a diagram
+function mermaidVisual(source) {
+ if (!/^\s*xychart/.test(source)) return { kind: 'diagram', source };
+ const labels = source.match(/x-axis\s*\[([^\]]*)\]/)?.[1];
+ const bars = source.match(/bar\s*\[([^\]]*)\]/)?.[1];
+ if (!labels || !bars) return;
+ const values = bars.split(',').map(Number);
+ return {
+ kind: 'chart',
+ chartTitle: source.match(/title\s+"([^"]*)"/)?.[1],
+ higherIsBetter: true,
+ rows: labels.split(',').map((l, i) => ({
+ label: l.trim().replace(/^"|"$/g, ''),
+ baseline: 100,
+ value: values[i],
+ })),
+ };
+}
+
+function imageVisual(src) {
+ const file = path.join(WEBSITE_ROOT, 'static', src);
+ return fs.existsSync(file) ? { kind: 'image', file } : undefined;
+}
+
+function codeVisual(meta, code) {
+ return {
+ kind: 'code',
+ title: meta.match(/title="([^"]+)"/)?.[1],
+ code: code.replace(/^\s*\/\/ highlight-next-line\n/gm, ''),
+ };
+}
+
+// Up to MAX_CODE_LINES, ending at a blank line when cut so statements stay whole
+function firstLines(code, max = MAX_CODE_LINES) {
+ // imports say little on a card, so skip them when other code follows
+ const body = code.replace(/^(import [^;]*;\s*\n)+\s*/, '');
+ const lines = (body.trim() ? body : code).split('\n');
+ if (lines.length <= max) return lines.join('\n');
+ const head = lines.slice(0, max);
+ const blank = head.lastIndexOf('');
+ return head.slice(0, blank > 0 ? blank : max).join('\n');
+}
+
+const KEYWORDS =
+ 'const|let|var|function|return|for|of|in|if|else|new|class|extends|import|export|from|await|async|static|this|typeof';
+const TOKEN_KINDS = ['comment', 'string', 'keyword', 'number', 'type', 'call'];
+const TOKEN = new RegExp(
+ [
+ '(\\/\\/.*)',
+ '(`[^`]*`|\'[^\']*\'|"[^"]*")',
+ `\\b(${KEYWORDS})\\b`,
+ '\\b(\\d+(?:\\.\\d+)?)\\b',
+ '\\b([A-Z]\\w*)',
+ '\\b(\\w+)(?=\\()',
+ ].join('|'),
+ 'g',
+);
+
+// Minimal TS highlighting: comments, strings, keywords, numbers, Types, calls
+function highlight(code) {
+ let html = '';
+ let last = 0;
+ for (const m of code.matchAll(TOKEN)) {
+ const kind = TOKEN_KINDS[m.slice(1).findIndex(g => g !== undefined)];
+ html += `${escape(code.slice(last, m.index))}${escape(m[0])}`;
+ last = m.index + m[0].length;
+ }
+ return html + escape(code.slice(last));
+}
+
+const fmt = n => (n >= 10 ? Math.round(n) : +n.toFixed(1));
+
+// Before vs after bars per row, with each row's multiplier
+function chartBody({
+ rows: allRows,
+ baselineLabel = 'Before',
+ valueLabel = 'After',
+ higherIsBetter,
+}) {
+ const rows = allRows.slice(0, MAX_CHART_ROWS);
+ const max = Math.max(...rows.flatMap(r => [r.baseline, r.value]));
+ const bar = (n, cls) =>
+ `
${firstLines(panel.code, maxLines)
+ .split('\n')
+ .map(line => `${highlight(line) || ' '}`)
+ .join('')}`;
+ }
+}
+
+function panelsHtml({ panels, speedup }) {
+ const n = panels.length;
+ const maxLines = n > 2 ? 5 : MAX_CODE_LINES;
+ const html = panels
+ .map((panel, i) => {
+ const title = (n === 1 && panel.chartTitle) || panel.title;
+ // the After side, else the first window, gets the accent title
+ const primary = panel.diff ? panel.diff === 'after' : i === 0;
+ const chip =
+ panel.speedup ?
+ `${escape(panel.speedup)}× faster`
+ : '';
+ return `