Star history for tangled repositories tangled-stars.com
atproto git tangled
1

Configure Feed

Select the types of activity you want to include in your feed.

Return /svg errors as an SVG card

juprodh.me (Jul 15, 2026, 9:37 PM +0800) 1efe3837 e00365cd

+142 -17
+85
backend/errorCard.ts
··· 1 + import { SVG_CHROME_FONT, svgTheme } from "../shared/packages/types.tsx" 2 + 3 + // What a stranger sees when /svg fails. 4 + // 5 + // This endpoint's whole job is to be an <img> in somebody else's README, and an <img> pointed at 6 + // `c.text("Repo name required", 400)` renders exactly one thing: a broken-image icon. The reader 7 + // gets no message, no cause, and no way to tell our outage from their typo — and the repo owner, 8 + // who is not looking at their README today, never finds out. So failures leave here as a 9 + // renderable card instead. The artifact has to travel even when it is bad news. 10 + 11 + const escapeXml = (s: string) => 12 + s 13 + .replace(/&/g, "&amp;") // first: later replacements introduce their own & 14 + .replace(/</g, "&lt;") 15 + .replace(/>/g, "&gt;") 16 + .replace(/"/g, "&quot;") 17 + .replace(/'/g, "&apos;") 18 + 19 + const FONT_SIZE = 15 20 + const LINE_HEIGHT = 20 21 + const PAD = 24 22 + const MAX_LINES = 4 23 + 24 + /** Greedy wrap. SVG <text> does not wrap, so a long message would otherwise run off the card. */ 25 + const wrapText = (text: string, maxChars: number): string[] => { 26 + const lines: string[] = [] 27 + let line = "" 28 + for (const word of text.split(/\s+/).filter(Boolean)) { 29 + // A single unbroken run longer than the line (a pasted repo name) has to be cut, not wrapped. 30 + if (word.length > maxChars) { 31 + if (line) { 32 + lines.push(line) 33 + line = "" 34 + } 35 + for (let i = 0; i < word.length; i += maxChars) lines.push(word.slice(i, i + maxChars)) 36 + line = lines.pop() ?? "" 37 + continue 38 + } 39 + if (!line) line = word 40 + else if (line.length + 1 + word.length <= maxChars) line += ` ${word}` 41 + else { 42 + lines.push(line) 43 + line = word 44 + } 45 + } 46 + if (line) lines.push(line) 47 + if (lines.length > MAX_LINES) return [...lines.slice(0, MAX_LINES - 1), `${lines[MAX_LINES - 1].slice(0, -1)}…`] 48 + return lines.length > 0 ? lines : ["Unknown error"] 49 + } 50 + 51 + export const renderErrorCard = ( 52 + message: string, 53 + { theme, width }: { theme: "light" | "dark"; width: number }, 54 + ): string => { 55 + const t = svgTheme[theme] 56 + // ~0.55em average advance for this stack, and never wider than a comfortable measure. 57 + const maxChars = Math.min(Math.floor((width - PAD * 2) / (FONT_SIZE * 0.55)), 72) 58 + const lines = wrapText(message, maxChars) 59 + 60 + const firstBaseline = 72 61 + const height = firstBaseline + (lines.length - 1) * LINE_HEIGHT + PAD 62 + 63 + const body = lines 64 + .map((l, i) => 65 + `<text x="${PAD}" y="${ 66 + firstBaseline + i * LINE_HEIGHT 67 + }" font-family="${SVG_CHROME_FONT}" font-size="${FONT_SIZE}" fill="${t.stroke}">${escapeXml(l)}</text>` 68 + ) 69 + .join("") 70 + 71 + // The border and the wordmark are the ink at low opacity rather than their own tokens: it 72 + // lands within a hair of --border and --dim in both themes, and can't drift from either. 73 + return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="${ 74 + escapeXml(message) 75 + }"><title>${escapeXml(message)}</title>` + 76 + `<rect x="0.5" y="0.5" width="${width - 1}" height="${ 77 + height - 1 78 + }" rx="6" fill="${t.background}" stroke="${t.stroke}" stroke-opacity="0.15"/>` + 79 + `<path transform="translate(${PAD},22) scale(0.6)" fill="${t.star}" d="M12 0 L15.18 8.82 L24 12 L15.18 15.18 L12 24 L8.82 15.18 L0 12 L8.82 8.82 Z"/>` + 80 + `<text x="${ 81 + PAD + 22 82 + }" y="34" font-family="${SVG_CHROME_FONT}" font-size="14" fill="${t.stroke}" fill-opacity="0.65">tangled-stars</text>` + 83 + body + 84 + `</svg>` 85 + }
+36 -11
backend/main.ts
··· 17 17 import type { ChartMode } from "../shared/types/chart.ts" 18 18 import cache, { getAllCacheStats, recordCacheHit, recordCacheMiss, svgCache } from "./cache.ts" 19 19 import { fixJsdomSvgCasing, getBase64Image, getChartWidthWithSize } from "./utils.ts" 20 + import { renderErrorCard } from "./errorCard.ts" 20 21 import { CHART_SIZES, MAX_REPOS_PER_REQUEST } from "./const.ts" 21 22 22 23 const SVG_HEADERS = { 23 24 "Content-Type": "image/svg+xml;charset=utf-8", 24 25 "Cache-Control": "public, s-maxage=86400, max-age=86400", 26 + } as const 27 + 28 + // Error cards go out as 200 so an <img> will actually paint them, with the real status kept in a 29 + // header for anyone calling the endpoint directly. Never cached: the repo may finish backfilling a 30 + // minute later, or the outage may end, and a README pinned to a day-old error card is worse than 31 + // no card at all. 32 + const ERROR_SVG_HEADERS = { 33 + "Content-Type": "image/svg+xml;charset=utf-8", 34 + "Cache-Control": "no-store", 25 35 } as const 26 36 27 37 const app = new Hono() ··· 65 75 // Rendered star-history SVG for embedding. Example: 66 76 // /svg?repos=tangled.org/core&type=timeline&logscale&legend=bottom-right 67 77 app.get("/svg", async (c) => { 68 - const reposParam = c.req.query("repos") 69 - if (!reposParam) return c.text("Repo name required", 400) 70 - const repos = reposParam.split(",").filter(Boolean) 71 - if (repos.length > MAX_REPOS_PER_REQUEST) { 72 - return c.text(`Too many repos: max ${MAX_REPOS_PER_REQUEST} per request`, 400) 73 - } 74 - 75 78 // --- Star history chart params --- 79 + // Parsed before the repos are validated, because the error card is themed and sized too: a 80 + // failure inside a dark README should not flash a white card. 76 81 const theme = c.req.query("theme") ?? "" 77 82 const transparent = c.req.query("transparent") ?? "" 78 83 const typeParam = c.req.query("type") ?? "" ··· 80 85 const legendParam = c.req.query("legend") ?? "" 81 86 let type: ChartMode = "Date" 82 87 let size = c.req.query("size") ?? "" 88 + if (!CHART_SIZES.includes(size)) size = "laptop" 89 + 90 + const errorCard = (message: string, status: number) => 91 + c.body( 92 + renderErrorCard(message, { 93 + theme: theme === "dark" ? "dark" : "light", 94 + width: getChartWidthWithSize(size), 95 + }), 96 + 200, 97 + { ...ERROR_SVG_HEADERS, "X-Error-Status": String(status) }, 98 + ) 99 + 100 + const reposParam = c.req.query("repos") 101 + if (!reposParam) return errorCard("Repo name required", 400) 102 + const repos = reposParam.split(",").filter(Boolean) 103 + if (repos.length > MAX_REPOS_PER_REQUEST) { 104 + return errorCard(`Too many repos: max ${MAX_REPOS_PER_REQUEST} per request`, 400) 105 + } 83 106 84 107 if (typeParam) { 85 108 const lowerType = typeParam.toLowerCase() ··· 93 116 94 117 const useLogScale = logscaleParam !== undefined && logscaleParam !== "false" 95 118 const legendPosition: "top-left" | "bottom-right" = legendParam === "bottom-right" ? "bottom-right" : "top-left" 96 - if (!CHART_SIZES.includes(size)) size = "laptop" 97 119 98 120 // Check rendered SVG cache before any data fetching or rendering. 99 121 const svgCacheKey = `${repos.join(",")}|${type}|${size}|${theme}|${transparent}|${legendPosition}|${useLogScale}` ··· 137 159 const e = error as { status?: number; data?: string; message?: string } 138 160 const message = (typeof e.data === "string" ? e.data : e.message) || 139 161 "Some unexpected error happened, try again later" 140 - return c.text(message, (e.status ?? 400) as ContentfulStatusCode) 162 + return errorCard(message, e.status ?? 400) 141 163 } 142 164 } 143 165 ··· 146 168 const dom = new JSDOM(`<!DOCTYPE html><body></body>`) 147 169 const body = dom.window.document.querySelector("body") 148 170 const svg = dom.window.document.createElement("svg") as unknown as SVGSVGElement 149 - if (!body || !svg) return c.text("Failed to mock dom with JSDOM", 500) 171 + if (!body || !svg) return errorCard("Could not render the chart, try again later", 500) 150 172 151 173 body.append(svg) 152 174 svg.setAttribute("width", `${getChartWidthWithSize(size)}`) ··· 172 194 }, 173 195 ) 174 196 } catch (error) { 175 - return c.text(`Failed to generate chart, ${String(error)}`, 500) 197 + // Logged, not shown: the raw error is ours to debug, and this message renders in someone 198 + // else's README where a stack trace is noise at best. 199 + logger.error(`chart render failed for ${repos.join(",")}: ${error}`) 200 + return errorCard("Could not render the chart, try again later", 500) 176 201 } 177 202 178 203 const svgContent = fixJsdomSvgCasing(svg.outerHTML)
+15
shared/packages/types.tsx
··· 8 8 9 9 export type LegendPosition = "top-left" | "bottom-right" 10 10 11 + // Chrome colours for anything rendered as a standalone SVG. These mirror the CSS tokens in 12 + // `frontend/global.css` (--l/d-surface, --l/d-fg, --l/d-star) but have to be literals: these 13 + // artifacts are also rendered server-side for `/svg`, where no stylesheet exists to resolve a 14 + // var() against. Everything drawing one reads them from here — the chart, and the error card it 15 + // falls back to — so a change to the chart's background can't leave the card behind on the old one. 16 + export const svgTheme = { 17 + light: { background: "#ffffff", stroke: "#111827", star: "#d97706" }, 18 + dark: { background: "#1f2937", stroke: "#f9fafb", star: "#fbbf24" }, 19 + } as const 20 + 21 + // Concrete fonts before the generic, for the same reason the chart needs them: standalone SVGs 22 + // get opened where the embedded @font-face never loads, and resvg won't resolve a bare 23 + // `sans-serif` — the text silently vanishes. No xkcd here; the card is chrome, not chart. 24 + export const SVG_CHROME_FONT = "Arial, Helvetica Neue, Noto Sans, DejaVu Sans, sans-serif" 25 + 11 26 // Series palette (light theme). Index 0 is the Tangled "star" amber (--l-star), so a 12 27 // single-repo chart matches the brand; index 1 is the green action accent (--l-accent). 13 28 // The rest are a sober, mutually-distinguishable sequence for multi-repo comparison —
+6 -6
shared/packages/xy-chart.tsx
··· 13 13 import drawLegend from "./utils/drawLegend.tsx" 14 14 import { drawWatermark } from "./utils/drawWatermark.tsx" 15 15 import getFormatTimeline, { getTimestampFormatUnit } from "./utils/getFormatTimeline.tsx" 16 - import { colors, D3Selection, darkColors, LegendPosition, Position } from "./types.tsx" 16 + import { colors, D3Selection, darkColors, LegendPosition, Position, svgTheme } from "./types.tsx" 17 17 18 18 const margin = { 19 19 top: 50, ··· 84 84 // this the text vanishes (resvg) or jumps to a wide serif and overlaps. 85 85 fontFamily: "xkcd, Arial, 'Helvetica Neue', 'Noto Sans', 'DejaVu Sans', sans-serif", 86 86 // Light surface + near-black ink, matching the redesign tokens (--l-surface / --l-text). 87 - backgroundColor: transparent ? "transparent" : "#ffffff", 88 - strokeColor: "#111827", 87 + backgroundColor: transparent ? "transparent" : svgTheme.light.background, 88 + strokeColor: svgTheme.light.stroke, 89 89 legendPosition: "top-left", 90 90 } 91 91 } ··· 95 95 ...getDefaultOptions(transparent), 96 96 dataColors: darkColors, 97 97 // Dark surface + near-white ink, matching the redesign tokens (--d-surface / --d-text). 98 - backgroundColor: transparent ? "transparent" : "#1f2937", 99 - strokeColor: "#f9fafb", 98 + backgroundColor: transparent ? "transparent" : svgTheme.dark.background, 99 + strokeColor: svgTheme.dark.stroke, 100 100 } 101 101 } 102 102 ··· 228 228 const svgChart = chart.append("g").attr("pointer-events", "all") 229 229 230 230 // Match the star line's gold (series-0 color) so the mark reads on both themes. 231 - const starColor = theme === "dark" ? "#fbbf24" : "#d97706" 231 + const starColor = theme === "dark" ? svgTheme.dark.star : svgTheme.light.star 232 232 drawWatermark(svgChart, chartWidth, chartHeight, margin.bottom, starColor, options.strokeColor) 233 233 234 234 if (title) {