···11+import { SVG_CHROME_FONT, svgTheme } from "../shared/packages/types.tsx"
22+33+// What a stranger sees when /svg fails.
44+//
55+// This endpoint's whole job is to be an <img> in somebody else's README, and an <img> pointed at
66+// `c.text("Repo name required", 400)` renders exactly one thing: a broken-image icon. The reader
77+// gets no message, no cause, and no way to tell our outage from their typo — and the repo owner,
88+// who is not looking at their README today, never finds out. So failures leave here as a
99+// renderable card instead. The artifact has to travel even when it is bad news.
1010+1111+const escapeXml = (s: string) =>
1212+ s
1313+ .replace(/&/g, "&") // first: later replacements introduce their own &
1414+ .replace(/</g, "<")
1515+ .replace(/>/g, ">")
1616+ .replace(/"/g, """)
1717+ .replace(/'/g, "'")
1818+1919+const FONT_SIZE = 15
2020+const LINE_HEIGHT = 20
2121+const PAD = 24
2222+const MAX_LINES = 4
2323+2424+/** Greedy wrap. SVG <text> does not wrap, so a long message would otherwise run off the card. */
2525+const wrapText = (text: string, maxChars: number): string[] => {
2626+ const lines: string[] = []
2727+ let line = ""
2828+ for (const word of text.split(/\s+/).filter(Boolean)) {
2929+ // A single unbroken run longer than the line (a pasted repo name) has to be cut, not wrapped.
3030+ if (word.length > maxChars) {
3131+ if (line) {
3232+ lines.push(line)
3333+ line = ""
3434+ }
3535+ for (let i = 0; i < word.length; i += maxChars) lines.push(word.slice(i, i + maxChars))
3636+ line = lines.pop() ?? ""
3737+ continue
3838+ }
3939+ if (!line) line = word
4040+ else if (line.length + 1 + word.length <= maxChars) line += ` ${word}`
4141+ else {
4242+ lines.push(line)
4343+ line = word
4444+ }
4545+ }
4646+ if (line) lines.push(line)
4747+ if (lines.length > MAX_LINES) return [...lines.slice(0, MAX_LINES - 1), `${lines[MAX_LINES - 1].slice(0, -1)}…`]
4848+ return lines.length > 0 ? lines : ["Unknown error"]
4949+}
5050+5151+export const renderErrorCard = (
5252+ message: string,
5353+ { theme, width }: { theme: "light" | "dark"; width: number },
5454+): string => {
5555+ const t = svgTheme[theme]
5656+ // ~0.55em average advance for this stack, and never wider than a comfortable measure.
5757+ const maxChars = Math.min(Math.floor((width - PAD * 2) / (FONT_SIZE * 0.55)), 72)
5858+ const lines = wrapText(message, maxChars)
5959+6060+ const firstBaseline = 72
6161+ const height = firstBaseline + (lines.length - 1) * LINE_HEIGHT + PAD
6262+6363+ const body = lines
6464+ .map((l, i) =>
6565+ `<text x="${PAD}" y="${
6666+ firstBaseline + i * LINE_HEIGHT
6767+ }" font-family="${SVG_CHROME_FONT}" font-size="${FONT_SIZE}" fill="${t.stroke}">${escapeXml(l)}</text>`
6868+ )
6969+ .join("")
7070+7171+ // The border and the wordmark are the ink at low opacity rather than their own tokens: it
7272+ // lands within a hair of --border and --dim in both themes, and can't drift from either.
7373+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="${
7474+ escapeXml(message)
7575+ }"><title>${escapeXml(message)}</title>` +
7676+ `<rect x="0.5" y="0.5" width="${width - 1}" height="${
7777+ height - 1
7878+ }" rx="6" fill="${t.background}" stroke="${t.stroke}" stroke-opacity="0.15"/>` +
7979+ `<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"/>` +
8080+ `<text x="${
8181+ PAD + 22
8282+ }" y="34" font-family="${SVG_CHROME_FONT}" font-size="14" fill="${t.stroke}" fill-opacity="0.65">tangled-stars</text>` +
8383+ body +
8484+ `</svg>`
8585+}
+36-11
backend/main.ts
···1717import type { ChartMode } from "../shared/types/chart.ts"
1818import cache, { getAllCacheStats, recordCacheHit, recordCacheMiss, svgCache } from "./cache.ts"
1919import { fixJsdomSvgCasing, getBase64Image, getChartWidthWithSize } from "./utils.ts"
2020+import { renderErrorCard } from "./errorCard.ts"
2021import { CHART_SIZES, MAX_REPOS_PER_REQUEST } from "./const.ts"
21222223const SVG_HEADERS = {
2324 "Content-Type": "image/svg+xml;charset=utf-8",
2425 "Cache-Control": "public, s-maxage=86400, max-age=86400",
2626+} as const
2727+2828+// Error cards go out as 200 so an <img> will actually paint them, with the real status kept in a
2929+// header for anyone calling the endpoint directly. Never cached: the repo may finish backfilling a
3030+// minute later, or the outage may end, and a README pinned to a day-old error card is worse than
3131+// no card at all.
3232+const ERROR_SVG_HEADERS = {
3333+ "Content-Type": "image/svg+xml;charset=utf-8",
3434+ "Cache-Control": "no-store",
2535} as const
26362737const app = new Hono()
···6575// Rendered star-history SVG for embedding. Example:
6676// /svg?repos=tangled.org/core&type=timeline&logscale&legend=bottom-right
6777app.get("/svg", async (c) => {
6868- const reposParam = c.req.query("repos")
6969- if (!reposParam) return c.text("Repo name required", 400)
7070- const repos = reposParam.split(",").filter(Boolean)
7171- if (repos.length > MAX_REPOS_PER_REQUEST) {
7272- return c.text(`Too many repos: max ${MAX_REPOS_PER_REQUEST} per request`, 400)
7373- }
7474-7578 // --- Star history chart params ---
7979+ // Parsed before the repos are validated, because the error card is themed and sized too: a
8080+ // failure inside a dark README should not flash a white card.
7681 const theme = c.req.query("theme") ?? ""
7782 const transparent = c.req.query("transparent") ?? ""
7883 const typeParam = c.req.query("type") ?? ""
···8085 const legendParam = c.req.query("legend") ?? ""
8186 let type: ChartMode = "Date"
8287 let size = c.req.query("size") ?? ""
8888+ if (!CHART_SIZES.includes(size)) size = "laptop"
8989+9090+ const errorCard = (message: string, status: number) =>
9191+ c.body(
9292+ renderErrorCard(message, {
9393+ theme: theme === "dark" ? "dark" : "light",
9494+ width: getChartWidthWithSize(size),
9595+ }),
9696+ 200,
9797+ { ...ERROR_SVG_HEADERS, "X-Error-Status": String(status) },
9898+ )
9999+100100+ const reposParam = c.req.query("repos")
101101+ if (!reposParam) return errorCard("Repo name required", 400)
102102+ const repos = reposParam.split(",").filter(Boolean)
103103+ if (repos.length > MAX_REPOS_PER_REQUEST) {
104104+ return errorCard(`Too many repos: max ${MAX_REPOS_PER_REQUEST} per request`, 400)
105105+ }
8310684107 if (typeParam) {
85108 const lowerType = typeParam.toLowerCase()
···9311694117 const useLogScale = logscaleParam !== undefined && logscaleParam !== "false"
95118 const legendPosition: "top-left" | "bottom-right" = legendParam === "bottom-right" ? "bottom-right" : "top-left"
9696- if (!CHART_SIZES.includes(size)) size = "laptop"
9711998120 // Check rendered SVG cache before any data fetching or rendering.
99121 const svgCacheKey = `${repos.join(",")}|${type}|${size}|${theme}|${transparent}|${legendPosition}|${useLogScale}`
···137159 const e = error as { status?: number; data?: string; message?: string }
138160 const message = (typeof e.data === "string" ? e.data : e.message) ||
139161 "Some unexpected error happened, try again later"
140140- return c.text(message, (e.status ?? 400) as ContentfulStatusCode)
162162+ return errorCard(message, e.status ?? 400)
141163 }
142164 }
143165···146168 const dom = new JSDOM(`<!DOCTYPE html><body></body>`)
147169 const body = dom.window.document.querySelector("body")
148170 const svg = dom.window.document.createElement("svg") as unknown as SVGSVGElement
149149- if (!body || !svg) return c.text("Failed to mock dom with JSDOM", 500)
171171+ if (!body || !svg) return errorCard("Could not render the chart, try again later", 500)
150172151173 body.append(svg)
152174 svg.setAttribute("width", `${getChartWidthWithSize(size)}`)
···172194 },
173195 )
174196 } catch (error) {
175175- return c.text(`Failed to generate chart, ${String(error)}`, 500)
197197+ // Logged, not shown: the raw error is ours to debug, and this message renders in someone
198198+ // else's README where a stack trace is noise at best.
199199+ logger.error(`chart render failed for ${repos.join(",")}: ${error}`)
200200+ return errorCard("Could not render the chart, try again later", 500)
176201 }
177202178203 const svgContent = fixJsdomSvgCasing(svg.outerHTML)
+15
shared/packages/types.tsx
···8899export type LegendPosition = "top-left" | "bottom-right"
10101111+// Chrome colours for anything rendered as a standalone SVG. These mirror the CSS tokens in
1212+// `frontend/global.css` (--l/d-surface, --l/d-fg, --l/d-star) but have to be literals: these
1313+// artifacts are also rendered server-side for `/svg`, where no stylesheet exists to resolve a
1414+// var() against. Everything drawing one reads them from here — the chart, and the error card it
1515+// falls back to — so a change to the chart's background can't leave the card behind on the old one.
1616+export const svgTheme = {
1717+ light: { background: "#ffffff", stroke: "#111827", star: "#d97706" },
1818+ dark: { background: "#1f2937", stroke: "#f9fafb", star: "#fbbf24" },
1919+} as const
2020+2121+// Concrete fonts before the generic, for the same reason the chart needs them: standalone SVGs
2222+// get opened where the embedded @font-face never loads, and resvg won't resolve a bare
2323+// `sans-serif` — the text silently vanishes. No xkcd here; the card is chrome, not chart.
2424+export const SVG_CHROME_FONT = "Arial, Helvetica Neue, Noto Sans, DejaVu Sans, sans-serif"
2525+1126// Series palette (light theme). Index 0 is the Tangled "star" amber (--l-star), so a
1227// single-repo chart matches the brand; index 1 is the green action accent (--l-accent).
1328// The rest are a sober, mutually-distinguishable sequence for multi-repo comparison —
+6-6
shared/packages/xy-chart.tsx
···1313import drawLegend from "./utils/drawLegend.tsx"
1414import { drawWatermark } from "./utils/drawWatermark.tsx"
1515import getFormatTimeline, { getTimestampFormatUnit } from "./utils/getFormatTimeline.tsx"
1616-import { colors, D3Selection, darkColors, LegendPosition, Position } from "./types.tsx"
1616+import { colors, D3Selection, darkColors, LegendPosition, Position, svgTheme } from "./types.tsx"
17171818const margin = {
1919 top: 50,
···8484 // this the text vanishes (resvg) or jumps to a wide serif and overlaps.
8585 fontFamily: "xkcd, Arial, 'Helvetica Neue', 'Noto Sans', 'DejaVu Sans', sans-serif",
8686 // Light surface + near-black ink, matching the redesign tokens (--l-surface / --l-text).
8787- backgroundColor: transparent ? "transparent" : "#ffffff",
8888- strokeColor: "#111827",
8787+ backgroundColor: transparent ? "transparent" : svgTheme.light.background,
8888+ strokeColor: svgTheme.light.stroke,
8989 legendPosition: "top-left",
9090 }
9191}
···9595 ...getDefaultOptions(transparent),
9696 dataColors: darkColors,
9797 // Dark surface + near-white ink, matching the redesign tokens (--d-surface / --d-text).
9898- backgroundColor: transparent ? "transparent" : "#1f2937",
9999- strokeColor: "#f9fafb",
9898+ backgroundColor: transparent ? "transparent" : svgTheme.dark.background,
9999+ strokeColor: svgTheme.dark.stroke,
100100 }
101101}
102102···228228 const svgChart = chart.append("g").attr("pointer-events", "all")
229229230230 // Match the star line's gold (series-0 color) so the mark reads on both themes.
231231- const starColor = theme === "dark" ? "#fbbf24" : "#d97706"
231231+ const starColor = theme === "dark" ? svgTheme.dark.star : svgTheme.light.star
232232 drawWatermark(svgChart, chartWidth, chartHeight, margin.bottom, starColor, options.strokeColor)
233233234234 if (title) {