diff --git a/CLAUDE.md b/CLAUDE.md index d3bb022..1880a17 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,7 @@ npm run preview # preview the static build npm run deploy # build + wrangler deploy npm run wrangler:dev # wrangler local dev npm run typecheck # astro check only +npm run capture:demos # re-snapshot live demos → src/assets/demos/ npm run format # prettier --write . npm run format:check # prettier --check . ``` @@ -29,6 +30,7 @@ npm run format:check # prettier --check . - **Node** pinned to 22 LTS via `.nvmrc`. - **Featured repos** are hand-picked in `src/components/RepoGrid.astro` via the `FEATURED_NAMES` array; everything else from `tightknitai` renders sorted by stars. - **GitHub fetch** at build time — set `GITHUB_TOKEN` to avoid the 60/hr unauthenticated limit. Fallback list lives in `src/lib/github.ts` so the build never breaks. +- **Live demos** are mapped in `DEMO_URLS` (`src/lib/github.ts`) and shown as a screenshot on the repo card. Re-capture with `npm run capture:demos` after a demo's UI changes; the PNG filename must match the repo name. If a demo URL is set but no screenshot exists yet, the card falls back to a text pill. ## Design system diff --git a/package-lock.json b/package-lock.json index 6df80d8..295a2d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@astrojs/check": "^0.9.9", "@types/node": "^25.8.0", "lefthook": "^2.1.6", + "playwright": "^1.60.0", "prettier": "^3.8.3", "prettier-plugin-astro": "^0.14.1", "prettier-plugin-tailwindcss": "^0.8.0", @@ -981,9 +982,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1000,9 +998,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1019,9 +1014,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1038,9 +1030,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1057,9 +1046,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1076,9 +1062,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1095,9 +1078,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1114,9 +1094,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1133,9 +1110,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1158,9 +1132,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1183,9 +1154,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1208,9 +1176,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1233,9 +1198,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1258,9 +1220,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1283,9 +1242,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1308,9 +1264,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1589,9 +1542,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1605,9 +1555,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1621,9 +1568,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1637,9 +1581,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1653,9 +1594,6 @@ "cpu": [ "loong64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1669,9 +1607,6 @@ "cpu": [ "loong64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1685,9 +1620,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1701,9 +1633,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1717,9 +1646,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1733,9 +1659,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1749,9 +1672,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1765,9 +1685,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1781,9 +1698,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2113,9 +2027,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2132,9 +2043,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2151,9 +2059,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2170,9 +2075,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4024,9 +3926,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4047,9 +3946,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4070,9 +3966,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4093,9 +3986,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5276,6 +5166,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", diff --git a/package.json b/package.json index 3a82895..c5df9da 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "deploy": "npm run build && wrangler deploy", "wrangler:dev": "wrangler dev", "typecheck": "astro check", + "capture:demos": "node --experimental-strip-types scripts/capture-demos.ts", "format": "prettier --write .", "format:check": "prettier --check .", "prepare": "lefthook install" @@ -25,6 +26,7 @@ "@astrojs/check": "^0.9.9", "@types/node": "^25.8.0", "lefthook": "^2.1.6", + "playwright": "^1.60.0", "prettier": "^3.8.3", "prettier-plugin-astro": "^0.14.1", "prettier-plugin-tailwindcss": "^0.8.0", diff --git a/scripts/capture-demos.ts b/scripts/capture-demos.ts new file mode 100644 index 0000000..187fef9 --- /dev/null +++ b/scripts/capture-demos.ts @@ -0,0 +1,94 @@ +/** + * Captures a viewport screenshot of each live demo and writes it to + * `src/assets/demos/.png`. The filename matches the repo `name` + * field so `RepoCard.astro` can resolve the image by repo name. + * + * Run manually after a demo's UI changes: + * + * npm run capture:demos + * + * Keep TARGETS in sync with DEMO_URLS in src/lib/github.ts. + */ +import { chromium, type Browser } from 'playwright'; +import { mkdir } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +interface Target { + /** Matches the GitHub repo name. The screenshot is saved as `${name}.png`. */ + name: string; + url: string; + /** + * Optional CSS selector to wait for before screenshotting — useful for demos + * where the canvas mounts after a network request. + */ + waitFor?: string; +} + +const TARGETS: Target[] = [ + { name: 'block-kitchen', url: 'https://block-kitchen.tightknit.dev' }, + { name: 'slack-block-kit-validator', url: 'https://block-kit-validator.tightknit.dev' }, + { name: 'storybook-addon-slack-block-kit', url: 'https://block-kit-storybook.tightknit.dev' }, +]; + +const VIEWPORT = { width: 1600, height: 900 } as const; +const DEVICE_SCALE_FACTOR = 2; // retina-quality output, ~3200x1800 PNG + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const OUT_DIR = resolve(__dirname, '../src/assets/demos'); + +async function capture(browser: Browser, target: Target): Promise { + const ctx = await browser.newContext({ + viewport: VIEWPORT, + deviceScaleFactor: DEVICE_SCALE_FACTOR, + colorScheme: 'light', + }); + const page = await ctx.newPage(); + try { + console.log(`→ ${target.name} ${target.url}`); + await page.goto(target.url, { waitUntil: 'networkidle', timeout: 30_000 }); + if (target.waitFor) await page.waitForSelector(target.waitFor, { timeout: 15_000 }); + // Settle fonts + late paints + await page.waitForTimeout(1200); + const out = resolve(OUT_DIR, `${target.name}.png`); + await page.screenshot({ path: out, fullPage: false, type: 'png' }); + console.log(` ✓ ${out}`); + } finally { + await ctx.close(); + } +} + +async function main(): Promise { + await mkdir(OUT_DIR, { recursive: true }); + // Optional per-host pin: pass `--host-rules="MAP host.example 1.2.3.4"` to + // bypass the system resolver when a stale negative cache is in the way. + const hostRulesArg = process.argv.find((a) => a.startsWith('--host-rules=')); + const launchArgs: string[] = ['--disable-features=AsyncDns,UseDnsHttpsSvcb']; + if (hostRulesArg) + launchArgs.push(`--host-resolver-rules=${hostRulesArg.split('=').slice(1).join('=')}`); + + const browser = await chromium.launch({ args: launchArgs }); + const results: { name: string; ok: boolean; error?: string }[] = []; + try { + for (const t of TARGETS) { + try { + await capture(browser, t); + results.push({ name: t.name, ok: true }); + } catch (err) { + const msg = err instanceof Error ? err.message.split('\n')[0] : String(err); + console.error(` ✗ ${t.name}: ${msg}`); + results.push({ name: t.name, ok: false, error: msg }); + } + } + } finally { + await browser.close(); + } + const ok = results.filter((r) => r.ok).length; + console.log(`\n${ok}/${results.length} captured. Output: ${OUT_DIR}`); + if (ok < results.length) process.exit(1); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/assets/demos/block-kitchen.png b/src/assets/demos/block-kitchen.png new file mode 100644 index 0000000..25d46e9 Binary files /dev/null and b/src/assets/demos/block-kitchen.png differ diff --git a/src/assets/demos/slack-block-kit-validator.png b/src/assets/demos/slack-block-kit-validator.png new file mode 100644 index 0000000..20b6d22 Binary files /dev/null and b/src/assets/demos/slack-block-kit-validator.png differ diff --git a/src/assets/demos/storybook-addon-slack-block-kit.png b/src/assets/demos/storybook-addon-slack-block-kit.png new file mode 100644 index 0000000..092a15a Binary files /dev/null and b/src/assets/demos/storybook-addon-slack-block-kit.png differ diff --git a/src/components/RepoCard.astro b/src/components/RepoCard.astro index 56d2864..ece4fa5 100644 --- a/src/components/RepoCard.astro +++ b/src/components/RepoCard.astro @@ -1,6 +1,8 @@ --- import type { Repo } from '../lib/github'; import { formatRelativeTime } from '../lib/github'; +import { Image } from 'astro:assets'; +import type { ImageMetadata } from 'astro'; interface Props { repo: Repo; @@ -22,13 +24,21 @@ const headingClass = featured ? 'text-3xl sm:text-4xl lg:text-5xl' : 'text-2xl s const isInverse = variant === 'pink'; const subTextColor = isInverse ? 'text-cream/85' : 'text-ink/80'; const dividerColor = isInverse ? 'border-cream/25' : 'border-ink/15'; + +const demoUrl = repo.demoUrl; +const demoHost = demoUrl ? new URL(demoUrl).host : null; + +// Demo screenshots are static PNGs in src/assets/demos/, captured by +// scripts/capture-demos.ts. Filenames must match repo.name exactly so this +// glob resolves them by name. Add a new demo: capture, drop file, done. +const demoImages = import.meta.glob<{ default: ImageMetadata }>('../assets/demos/*.png', { + eager: true, +}); +const demoImage = demoImages[`../assets/demos/${repo.name}.png`]?.default ?? null; --- -
@@ -43,7 +53,14 @@ const dividerColor = isInverse ? 'border-cream/25' : 'border-ink/15';

- {repo.name} + + {repo.name} +

{ @@ -52,6 +69,57 @@ const dividerColor = isInverse ? 'border-cream/25' : 'border-ink/15'; ) } + { + demoUrl && demoImage && demoHost && ( + + {`Preview + + + + Live demo + + + {demoHost} + + + + + ) + } + + { + /* Fallback pill: demo URL set but no screenshot captured yet. Keeps the + demo discoverable while the next capture run is pending. */ + demoUrl && !demoImage && demoHost && ( + + + Live demo + · + {demoHost} + + ) + } + { repo.topics.length > 0 && (
@@ -84,4 +152,4 @@ const dividerColor = isInverse ? 'border-cream/25' : 'border-ink/15'; } {formatRelativeTime(repo.pushedAt)}
- + diff --git a/src/lib/github.ts b/src/lib/github.ts index e3f5979..4033644 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -4,6 +4,7 @@ export interface Repo { description: string | null; url: string; homepage: string | null; + demoUrl: string | null; stars: number; forks: number; language: string | null; @@ -14,6 +15,15 @@ export interface Repo { fork: boolean; } +// Hand-curated live demos. The GitHub `homepage` field often points to npm or docs, +// so we keep these separate. Add a repo here when there's something the visitor can +// actually click and play with. +const DEMO_URLS: Record = { + 'block-kitchen': 'https://block-kitchen.tightknit.dev', + 'slack-block-kit-validator': 'https://block-kit-validator.tightknit.dev', + 'storybook-addon-slack-block-kit': 'https://block-kit-storybook.tightknit.dev', +}; + interface GitHubRepoApi { name: string; full_name: string; @@ -110,6 +120,7 @@ export async function getOrgRepos(org: string): Promise { description: r.description ? r.description.replace(/\s*—\s*/g, ', ') : null, url: r.html_url, homepage: r.homepage, + demoUrl: DEMO_URLS[r.name] ?? null, stars: r.stargazers_count, forks: r.forks_count, language: r.language, @@ -131,6 +142,7 @@ const FALLBACK_REPOS: Repo[] = [ description: 'Compose Slack Block Kit messages with a React-style API.', url: 'https://github.com/tightknitai/block-kitchen', homepage: null, + demoUrl: null, stars: 0, forks: 0, language: 'TypeScript', @@ -146,6 +158,7 @@ const FALLBACK_REPOS: Repo[] = [ description: 'Validate Slack Block Kit payloads before you send them.', url: 'https://github.com/tightknitai/slack-block-kit-validator', homepage: null, + demoUrl: null, stars: 0, forks: 0, language: 'TypeScript', @@ -161,6 +174,7 @@ const FALLBACK_REPOS: Repo[] = [ description: 'Build Slack apps on Hono. Works on Workers, Bun, Node.', url: 'https://github.com/tightknitai/slack-hono', homepage: null, + demoUrl: null, stars: 0, forks: 0, language: 'TypeScript', @@ -183,6 +197,7 @@ const MANUAL_REPOS: Repo[] = [ description: 'Starter template for shipping a Slack app with slack-hono on Cloudflare Workers.', url: 'https://github.com/tightknitai/slack-hono-template', homepage: null, + demoUrl: null, stars: 0, forks: 0, language: 'TypeScript', @@ -198,7 +213,11 @@ function mergeManual(live: Repo[]): Repo[] { const liveNames = new Set(live.map((r) => r.name)); const additions = MANUAL_REPOS.filter((r) => !liveNames.has(r.name)); return [...live, ...additions] - .map((r) => ({ ...r, topics: sortTopics(r.topics) })) + .map((r) => ({ + ...r, + topics: sortTopics(r.topics), + demoUrl: r.demoUrl ?? DEMO_URLS[r.name] ?? null, + })) .sort((a, b) => b.stars - a.stars || a.name.localeCompare(b.name)); }