diff --git a/README.md b/README.md index 647dc32..41c9125 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,26 @@ ProfileKit cards are plain SVG. They render anywhere a platform allows external **Verified a new context?** Open a PR updating the table — the test URL is any ProfileKit endpoint, e.g. `https://profilekit.vercel.app/api/wave?text=test`. +## Related projects + +The GitHub-profile-card space has two long-running projects ProfileKit is most often compared against. The differences are stylistic, not "better/worse" — pick the one that matches how you want to write your README. + +| Axis | ProfileKit | [anuraghazra/github-readme-stats](https://github.com/anuraghazra/github-readme-stats) | [lowlighter/metrics](https://github.com/lowlighter/metrics) | +|---|---|---|---| +| Composition unit | 28 parameter-only URL endpoints | A handful of stat endpoints | Single rendered SVG built from 30+ plugins | +| Update model | Per-request, 30-min CDN cache (animations are SVG/CSS, not refreshes) | Per-request, CDN cache | GitHub Action runs on cron, commits the SVG into the repo | +| Configuration | Query string + optional `theme_url=` gist for palettes | Query string + per-theme presets | YAML in `.github/workflows/` | +| Runtime deps | Zero (Node 22 `node:test`, `node:fetch`) | Several | Action toolchain + Docker image | +| Cards beyond GitHub stats | Hero / section / divider / now / timeline / tags / toc / typing / wave / terminal / neon / glitch / matrix / snake / equalizer / heartbeat / constellation / radar / quote / posts (devto, medium, rss) | GitHub stats, languages, pin, gists | Mostly GitHub stats; plugin set is the largest of the three | +| MCP integration | First-class — [`@heznpc/profilekit-mcp`](https://www.npmjs.com/package/@heznpc/profilekit-mcp) lets Claude / Cursor / Codex CLI build cards as a tool call | None | None | +| Composition into one image | `/api/stack?cards=hero,section,now,…` | Not native | The whole point of metrics is a single composed image | + +**When ProfileKit fits well**: you want the same card definition usable in a GitHub README *and* a dev.to bio *and* an MCP tool call, you don't want a GitHub Action committing to your repo, and "no ranking, composable presentation" sounds right for your profile. + +**When the alternatives fit better**: if you want one giant pre-rendered SVG with 300+ knobs in YAML, `lowlighter/metrics` is the better tool. If you only need GitHub stats blocks and want the most adopted option, `anuraghazra/github-readme-stats` is the default. + +(Comparison reflects the state of all three repos as of 2026-05. Open a PR if any of these projects' models change materially.) + ## Endpoints | Endpoint | Description | diff --git a/tests/endpoint-headers.test.js b/tests/endpoint-headers.test.js new file mode 100644 index 0000000..382d951 --- /dev/null +++ b/tests/endpoint-headers.test.js @@ -0,0 +1,111 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +// Regression test for response headers ProfileKit relies on for correct +// rendering through GitHub's Camo proxy and other image proxies. +// +// Why this exists: market-pulse 2026-05 surfaced that GitHub's Camo cache +// behavior depends on a stable Content-Type and Cache-Control header. A +// silent change (someone "tidies up" an endpoint and drops `image/svg+xml`, +// or replaces `cacheHeaders()` with `no-store`) would result in Camo +// re-fetching every request and animations stuttering for embedders. None +// of the existing tests check the wire-level response headers. + +function makeMockReq(query = {}) { + // Vercel-style req: url + parsed query. parseSearchParams in options.js + // only reads `req.url`, so the query object isn't strictly needed, but we + // include it for handlers that hit `req.query` directly. + const qs = new URLSearchParams(query).toString(); + return { + url: qs ? `/api/_test?${qs}` : "/api/_test", + query, + method: "GET", + headers: { host: "profilekit.local" }, + }; +} + +function makeMockRes() { + const headers = {}; + let body; + let statusCode = 200; + return { + setHeader(name, value) { + headers[name] = value; + }, + getHeader(name) { + return headers[name]; + }, + status(code) { + statusCode = code; + return this; + }, + send(payload) { + body = payload; + return this; + }, + _inspect() { + return { headers, body, statusCode }; + }, + }; +} + +test("/api/divider emits Content-Type: image/svg+xml + cached", async () => { + // SVG endpoints must declare image/svg+xml so Camo accepts and caches + // them. A regression to text/html or application/octet-stream causes + // GitHub READMEs to render a broken-image icon. + const handler = require("../src/endpoints/divider"); + const req = makeMockReq({ style: "line", width: "400" }); + const res = makeMockRes(); + await handler(req, res); + const { headers, body } = res._inspect(); + assert.equal(headers["Content-Type"], "image/svg+xml", "Content-Type must be image/svg+xml"); + assert.match( + headers["Cache-Control"] || "", + /max-age=\d+/, + "Cache-Control must include a max-age so Camo caches and re-fetches deterministically" + ); + assert.ok(typeof body === "string" && body.startsWith(" { + // Probes (Pingdom, statuspage) and uptime monitors must observe the + // current pool state, not a cached snapshot. A regression to a long + // max-age would mask token-pool drainage incidents. + const handler = require("../src/endpoints/health"); + const res = makeMockRes(); + await handler(makeMockReq(), res); + const { headers, body } = res._inspect(); + assert.match(headers["Content-Type"], /application\/json/, "/api/health must return JSON"); + assert.match( + headers["Cache-Control"] || "", + /no-store/, + "/api/health must never be cached" + ); + const parsed = JSON.parse(body); + assert.equal(parsed.ok, true); + assert.ok(parsed.allowlists, "/api/health must surface allowlists for visibility"); +}); + +test("/api/catalog emits JSON + cached", async () => { + // Discovery endpoint consumed by @heznpc/profilekit-mcp. A regression + // to text/plain would break the wrapper's JSON.parse, and missing + // Cache-Control would hammer the function on every MCP discover call. + const handler = require("../src/endpoints/catalog"); + const res = makeMockRes(); + await handler(makeMockReq(), res); + const { headers, body } = res._inspect(); + assert.match(headers["Content-Type"], /application\/json/, "/api/catalog must return JSON"); + assert.match( + headers["Cache-Control"] || "", + /max-age=\d+/, + "/api/catalog must be cached so MCP discovery doesn't re-execute the handler per request" + ); + const parsed = JSON.parse(body); + assert.ok(parsed.cards && parsed.themes, "catalog must declare cards + themes"); + // Cross-check with the market-pulse-driven contract: theme_url must be + // advertised on every card endpoint, not just stats/stack. + assert.ok( + parsed.cards.hero.common_params.includes("theme_url"), + "hero must list theme_url in common_params (matches README claim)" + ); +});