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 = [ + [//g, perfChart], + [/```mermaid\n([\s\S]*?)\n```/g, mermaidVisual], + [/(?:!\[[^\]]*\]\(|]*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) => + `
`; + return `
${escape(baselineLabel)}${escape(valueLabel)}
${rows + .map(({ label, baseline, value }) => { + const speedup = higherIsBetter ? value / baseline : baseline / value; + return `
${escape(label)}${fmt(speedup)}×
${bar(baseline, 'base')}${bar(value, 'value')}
`; + }) + .join('')}`; +} + +function panelBody(panel, maxLines) { + switch (panel.kind) { + case 'chart': + return `
${chartBody(panel)}
`; + case 'diagram': + return `
${escape(panel.source)}
`; + case 'image': { + const ext = path.extname(panel.file); + const type = { '.svg': 'svg+xml', '.jpg': 'jpeg' }[ext] ?? ext.slice(1); + return `
`; + } + default: + return `
${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 `
${escape(title)}${chip}
${panelBody(panel, maxLines)}
`; + }) + .join(''); + return speedup ? + `${html}
${escape(speedup)}×
faster
` + : html; +} + +// A leading link's text, else the bullet's first clause +function bulletLabel(text) { + const link = text.match(/^\[([^\]]+)\]\(/); + if (link) return link[1].replace(/`/g, ''); + const plain = text + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/`/g, '') + .split(/[;,]| \(| - /)[0] + .trim(); + if (plain.length <= 40) return plain; + return plain.slice(0, 40).replace(/\s+\S*$/, '') + '…'; +} + +const escape = s => + s.replace( + /[&<>"]/g, + c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c], + ); + +// Each comma-separated feature gets a line, alternating white/blue. A single +// multi-word feature splits its last word onto a blue line instead. +function headlineLines(headline) { + const parts = headline.split(/,\s*/); + if (parts.length > 1) return parts; + const words = headline.split(' '); + return words.length > 1 ? + [words.slice(0, -1).join(' '), words.at(-1)] + : words; +} + +// Deterministic per-version so re-running produces the same image +function prng(seed) { + let s = [...seed].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7); + return () => (s = (s * 1664525 + 1013904223) >>> 0) / 2 ** 32; +} + +// Background motif and accent colors that fit the release's theme, picked from +// its title then description: speed streaks for performance, a node graph for +// schemas and data relationships, a grid for types, else flowing streams +const THEMES = [ + { + match: PERF, + draw: speedMotif, + colors: ['#3e96db', '#4fc3f7', '#22d3ee', '#f59e0b'], + }, + { + draw: networkMotif, + match: /schema|normaliz|entit|relation|graph|collection|union|query/i, + colors: ['#7c6cf0', '#a78bfa', '#3e96db', '#22d3ee'], + }, + { + draw: gridMotif, + match: /type|typescript/i, + colors: ['#2dd4bf', '#3e96db', '#5eead4', '#7c6cf0'], + }, +]; +const FLOW = { + draw: flowMotif, + colors: ['#3e96db', '#4fc3f7', '#7c6cf0', '#22d3ee'], +}; + +function pickTheme(headline, description) { + return ( + THEMES.find(t => t.match.test(headline)) ?? + THEMES.find(t => t.match.test(description)) ?? + FLOW + ); +} + +const fixed = (v, d = 1) => v.toFixed(d); +const GLOW = ``; + +function background({ colors, draw }, random) { + const pick = () => colors[Math.floor(random() * colors.length)]; + return `${GLOW}${draw(random, pick, colors)}`; +} + +function speedMotif(random, pick) { + let body = ''; + // motion streaks rushing left, each with a bright head + for (let i = 0; i < 60; i++) { + const y = HEIGHT * random(); + const x = WIDTH * (0.45 + random() * 0.5); + const len = 80 + random() * 380; + const c = pick(); + body += ``; + } + return body; +} + +function networkMotif(random, pick) { + let body = ''; + // nodes linked to their nearest neighbors + const nodes = Array.from({ length: 34 }, () => [ + WIDTH * (0.5 + random() * 0.5), + HEIGHT * random(), + ]); + for (const [i, [x, y]] of nodes.entries()) { + const near = nodes + .map(([x2, y2], j) => [Math.hypot(x2 - x, y2 - y), j]) + .filter(([, j]) => j > i) + .sort((a, b) => a[0] - b[0]) + .slice(0, 2); + for (const [, j] of near) + body += ``; + } + for (const [x, y] of nodes) + body += ``; + return body; +} + +function gridMotif(random, pick, colors) { + let body = ''; + // a perspective floor grid with a few lit nodes + const horizon = HEIGHT * 0.38; + const vx = WIDTH * 0.78; + for (let i = -14; i <= 14; i++) + body += ``; + for (let i = 1; i <= 12; i++) { + const y = horizon + (HEIGHT - horizon) * (i / 12) ** 2; + body += ``; + } + for (let i = 0; i < 18; i++) + body += ``; + return body; +} + +function flowMotif(random, pick) { + let body = ''; + // light streams fanning out to the right edge + for (let i = 0; i < 46; i++) { + const c = pick(); + const p0 = [WIDTH * 0.68, HEIGHT * (0.4 + random() * 0.2)]; + const p3 = [WIDTH, HEIGHT * (-0.05 + random() * 1.1)]; + const p1 = [WIDTH * 0.8, p0[1]]; + const p2 = [WIDTH * 0.86, p3[1]]; + const at = t => + [0, 1].map( + k => + (1 - t) ** 3 * p0[k] + + 3 * (1 - t) ** 2 * t * p1[k] + + 3 * (1 - t) * t ** 2 * p2[k] + + t ** 3 * p3[k], + ); + body += ``; + for (let j = 0; j < 3; j++) { + const [x, y] = at(0.45 + random() * 0.55); + body += ``; + } + } + return body; +} + +// Inline the site's fonts so rendering is offline and reproducible +function fontFace(family, file) { + const woff2 = fs.readFileSync( + path.join(WEBSITE_ROOT, 'static/font', file), + 'base64', + ); + return `@font-face { font-family: '${family}'; font-weight: 100 900; src: url(data:font/woff2;base64,${woff2}) format('woff2'); }`; +} + +function cardHtml({ version, headline, description, rows, feature }) { + const theme = pickTheme(headline, description); + const logo = fs + .readFileSync(path.join(WEBSITE_ROOT, 'static/img/client-logo.svg'), 'utf8') + .replace(/<\?xml[^>]*>|/g, ''); + const lines = headlineLines(headline) + .map( + (line, i) => + `
${escape(line)}
`, + ) + .join(''); + const panelRows = rows + .map( + ({ tag, label }) => + `
${tag}${escape(label)}
`, + ) + .join(''); + + return ` + +${background(theme, prng(version))} +
+
${logo}
REACTIVE
DATA CLIENT
+
v${escape(version)}RELEASE
+
${lines}
+
+
${escape(description)}
+
+${ + feature ? panelsHtml(feature) + : panelRows ? + `

WHAT'S NEW

${panelRows}
` + : '' +} +`; +} + +async function render(html, outFile, feature) { + const browser = await chromium.launch({ + executablePath: process.env.CHROMIUM_PATH, + }); + try { + const page = await browser.newPage({ + viewport: { width: WIDTH, height: HEIGHT }, + }); + await page.setContent(html); + const fontsLoaded = await page.evaluate(async () => + ( + await Promise.all( + ['600 40px Rubik', '500 20px "Roboto Mono"'].map(f => + document.fonts.load(f), + ), + ) + ).every(faces => faces.length), + ); + if (!fontsLoaded) throw new Error('Rubik/Roboto Mono failed to load'); + if (feature?.panels.some(p => p.kind === 'diagram')) { + // added after load, so mermaid doesn't render before this config + await page.addScriptTag({ + path: require.resolve('mermaid/dist/mermaid.min.js'), + }); + await page.evaluate(async () => { + window.mermaid.initialize({ + startOnLoad: false, + theme: 'base', + // svg text measures with the loaded fonts; html labels clip + htmlLabels: false, + flowchart: { htmlLabels: false }, + themeVariables: { + fontFamily: 'Rubik', + fontSize: '22px', + primaryColor: '#16213a', + primaryTextColor: '#e6edf7', + primaryBorderColor: '#4aa3ff', + lineColor: '#5eead4', + secondaryColor: '#1d2b4a', + tertiaryColor: '#0f1830', + }, + }); + await window.mermaid.run({ querySelector: '.diagram-src' }); + }); + } + // Shrink the headline and code until every line fits, with real font metrics + await page.evaluate(() => { + for (const box of document.querySelectorAll('#headline, pre')) { + let size = parseFloat(getComputedStyle(box).fontSize); + while ( + size > 14 && + (box.scrollHeight > box.clientHeight || + [...box.children].some(l => l.scrollWidth > box.clientWidth)) + ) + box.style.fontSize = --size + 'px'; + } + }); + await page.screenshot({ path: outFile }); + } finally { + await browser.close(); + } +} + +async function main() { + const { positionals, values } = parseArgs({ + allowPositionals: true, + options: { force: { type: 'boolean' } }, + }); + const postFile = findPost(positionals[0]); + const post = await parsePost(postFile); + const outFile = path.join(OUT_DIR, `${post.version}-card.png`); + const rel = path.relative(process.cwd(), outFile); + if (fs.existsSync(outFile) && !values.force) + throw new Error(`${rel} exists; pass --force to overwrite`); + await render(cardHtml(post), outFile, post.feature); + console.log(`Wrote ${rel}`); + const image = `/img/social/${post.version}-card.png`; + if (post.image !== image) + console.log( + `Add to ${path.basename(postFile)} frontmatter:\n image: ${image}`, + ); +} + +main().catch(e => { + console.error(e.message); + process.exit(1); +}); diff --git a/website/static/img/social/0.19-card.png b/website/static/img/social/0.19-card.png new file mode 100644 index 000000000000..db9acb8728cb Binary files /dev/null and b/website/static/img/social/0.19-card.png differ diff --git a/yarn.lock b/yarn.lock index f119f5a1d55d..f9f7e9540a7c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23926,6 +23926,7 @@ __metadata: clsx: "npm:2.1.1" esbuild: "npm:~0.28.0" mdast-util-phrasing: "npm:^4.0.0" + mermaid: "npm:>=11.6.0" monaco-editor: "npm:^0.57.0" parse-numeric-range: "npm:^1.3.0" playwright: "npm:1.61.1"