diff --git a/Dockerfile b/Dockerfile index c1390a6..c994596 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ COPY package.json package-lock.json ${LAMBDA_TASK_ROOT} RUN npm install # Copy function code -COPY index.js satori_renderer.js tokenizer.js ${LAMBDA_TASK_ROOT}/ +COPY index.js satori_renderer.js profile_renderer.js tokenizer.js ${LAMBDA_TASK_ROOT}/ COPY assets ${LAMBDA_TASK_ROOT}/assets # Set the CMD to your handler (could also be done as a parameter override outside of the Dockerfile) diff --git a/README.md b/README.md index a2e07d8..e9afc3e 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,45 @@ anything to photograph. It costs a few seconds at 2GB per image, and because it fetches our own public site from a datacentre IP it looks like a bot - which is how enabling Cloudflare's bot mitigation broke image generation for four days. -**satori** draws solution images without a browser. The website serves the data -directly at `/images/solutions/:track/:exercise/:handle/data`, which leaves -plain layout, and satori does layout in-process. Roughly 100-300ms instead of -~5.7s, and no headless Chrome. - -Set `RENDERER=satori` to enable it. Profiles have no satori renderer yet and -always use Chrome, as does everything if the variable is unset. +**satori** draws both solution and profile images without a browser. The +website serves the data directly over the internal ALB, which leaves plain +layout, and satori does layout in-process. Roughly 100-300ms instead of ~5.7s, +and no headless Chrome. + +Set `RENDERER=satori` to enable it. Everything uses Chrome if the variable is +unset. + +Profiles are the harder of the two, because the page they replace isn't just +text in a box: + +- The **radar chart** is Chart.js on a ``. satori has neither, so it's + redrawn as inline SVG from the same six numbers — `padReputation()` and the + 12-o'clock start are carried over from `use-chart.ts` so the polygon matches. +- **Header and category icons** — the reputation shield, the flair beside the + handle, the `{~}` in the founder tag, and the six hexagonal category icons — + are the website's own SVGs, vendored under `assets/profile-icons` by + `dev/sync-profile-icons.sh ../website`. The hexagon behind each category + glyph isn't an icon file upstream but `--backgroundImageHex`, a data URI in + `app/css/ui-kit/colors.css`; the script extracts the dark-theme one of the + two. Nothing is fetched at render time — satori resolves image sources over + the network, and a profile draws a dozen of them, so a live fetch would be a + dozen more ways for an image to hang. + + The icons are recoloured by rewriting their fills, since there's no CSS + filter here to do it. Most of them carry `fill="none"` on the `` element + and leave their paths to inherit it, so that root fill is re-applied to the + group that replaces the stripped root — without it the outlines fall back to + SVG's default black and flood. +- **Badge medallions** are the website's own artwork, lifted out of the base64 + data URIs in `app/css/components/badge.css` and vendored under + `assets/medallions`. Their rarity glows are CSS filters; satori has no + filters, but these are SVG handed to resvg, which does support + `feGaussianBlur`, so the glow survives. +- **Badge icons** are vendored too, under `assets/badge-icons`, along with the + badge→icon mapping — which is *not* derivable from the badge name + (`ContributorBadge` uses `contributors`, `RookieBadge` uses `editor`). Run + `dev/sync-badge-icons.sh ../website` when a badge is added or its icon + changes. ### Colours without a stylesheet diff --git a/assets/badge-icons/12in23-start.svg b/assets/badge-icons/12in23-start.svg new file mode 100644 index 0000000..9549313 --- /dev/null +++ b/assets/badge-icons/12in23-start.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/badge-icons/48in24-participant.svg b/assets/badge-icons/48in24-participant.svg new file mode 100644 index 0000000..556cccd --- /dev/null +++ b/assets/badge-icons/48in24-participant.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/assets/badge-icons/all-your-base.svg b/assets/badge-icons/all-your-base.svg new file mode 100644 index 0000000..702bbf2 --- /dev/null +++ b/assets/badge-icons/all-your-base.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/assets/badge-icons/architect.svg b/assets/badge-icons/architect.svg new file mode 100644 index 0000000..7c47d1b --- /dev/null +++ b/assets/badge-icons/architect.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/assets/badge-icons/badge-analytical-april.svg b/assets/badge-icons/badge-analytical-april.svg new file mode 100644 index 0000000..66b2a71 --- /dev/null +++ b/assets/badge-icons/badge-analytical-april.svg @@ -0,0 +1 @@ + diff --git a/assets/badge-icons/badge-apps-august.svg b/assets/badge-icons/badge-apps-august.svg new file mode 100644 index 0000000..3013b9e --- /dev/null +++ b/assets/badge-icons/badge-apps-august.svg @@ -0,0 +1 @@ + diff --git a/assets/badge-icons/badge-completed-12-in-23.svg b/assets/badge-icons/badge-completed-12-in-23.svg new file mode 100644 index 0000000..56c1c3b --- /dev/null +++ b/assets/badge-icons/badge-completed-12-in-23.svg @@ -0,0 +1 @@ + diff --git a/assets/badge-icons/badge-december-diversions.svg b/assets/badge-icons/badge-december-diversions.svg new file mode 100644 index 0000000..6ccb162 --- /dev/null +++ b/assets/badge-icons/badge-december-diversions.svg @@ -0,0 +1,2 @@ + + diff --git a/assets/badge-icons/badge-icons.json b/assets/badge-icons/badge-icons.json new file mode 100644 index 0000000..07b60b0 --- /dev/null +++ b/assets/badge-icons/badge-icons.json @@ -0,0 +1,190 @@ +{ + "all_your_base": { + "rarity": "rare", + "icon": "all-your-base" + }, + "analytical_april": { + "rarity": "rare", + "icon": "badge-analytical-april" + }, + "anybody_there": { + "rarity": "rare", + "icon": "hello-world" + }, + "apps_august": { + "rarity": "rare", + "icon": "badge-apps-august" + }, + "architect": { + "rarity": "legendary", + "icon": "architect" + }, + "bard": { + "rarity": "legendary", + "icon": "bard" + }, + "begetter": { + "rarity": "legendary", + "icon": "begetter" + }, + "chatterbox": { + "rarity": "common", + "icon": "chatterbox" + }, + "completed_12_in_23": { + "rarity": "legendary", + "icon": "badge-completed-12-in-23" + }, + "completer": { + "rarity": "ultimate", + "icon": "completer" + }, + "conceptual": { + "rarity": "ultimate", + "icon": "conceptual" + }, + "contributor": { + "rarity": "ultimate", + "icon": "contributors" + }, + "december_diversions": { + "rarity": "rare", + "icon": "badge-december-diversions" + }, + "die_unendliche_geschichte": { + "rarity": "rare", + "icon": "die-unendliche-geschichte" + }, + "discourser": { + "rarity": "common", + "icon": "discourser" + }, + "functional_february": { + "rarity": "rare", + "icon": "functional" + }, + "growth_mindset": { + "rarity": "common", + "icon": "mentoring" + }, + "insider": { + "rarity": "ultimate", + "icon": "insiders" + }, + "jurassic_july": { + "rarity": "rare", + "icon": "badge-jurassic-july" + }, + "lackadaisical": { + "rarity": "ultimate", + "icon": "lackadaisical" + }, + "larisa_latynina": { + "rarity": "rare", + "icon": "larisa-latynina" + }, + "lifetime_insider": { + "rarity": "legendary", + "icon": "lifetime-insiders" + }, + "mechanical_march": { + "rarity": "rare", + "icon": "badge-machine-code" + }, + "member": { + "rarity": "common", + "icon": "logo" + }, + "mentor": { + "rarity": "rare", + "icon": "mentor" + }, + "mind_shifting_may": { + "rarity": "rare", + "icon": "badge-mind-shifting-may" + }, + "moss": { + "rarity": "legendary", + "icon": "moss" + }, + "new_years_resolution": { + "rarity": "rare", + "icon": "new-years-resolution" + }, + "nibbly_november": { + "rarity": "rare", + "icon": "badge-nibbly-november" + }, + "object_oriented_october": { + "rarity": "rare", + "icon": "badge-object-oriented-october" + }, + "paavo_nurmi": { + "rarity": "ultimate", + "icon": "paavo-nurmi" + }, + "participant_in_12_in_23": { + "rarity": "rare", + "icon": "12in23-start" + }, + "participant_in_48_in_24": { + "rarity": "common", + "icon": "48in24-participant" + }, + "polyglot": { + "rarity": "legendary", + "icon": "badge-polyglot" + }, + "researcher": { + "rarity": "legendary", + "icon": "researcher" + }, + "rookie": { + "rarity": "common", + "icon": "editor" + }, + "slimline_september": { + "rarity": "rare", + "icon": "badge-slimline-september" + }, + "summer_of_sexps": { + "rarity": "rare", + "icon": "badge-summer-of-sexps" + }, + "supermentor": { + "rarity": "legendary", + "icon": "supermentor" + }, + "supporter": { + "rarity": "rare", + "icon": "supporter" + }, + "tooling_pioneer": { + "rarity": "legendary", + "icon": "tooling-pioneer" + }, + "troubleshooter": { + "rarity": "legendary", + "icon": "troubleshooter" + }, + "usain_bolt": { + "rarity": "legendary", + "icon": "usain-bolt" + }, + "v1": { + "rarity": "common", + "icon": "v1" + }, + "v2": { + "rarity": "common", + "icon": "v2" + }, + "v3_pioneer": { + "rarity": "legendary", + "icon": "v3-pioneer" + }, + "whatever": { + "rarity": "common", + "icon": "whatever" + } +} diff --git a/assets/badge-icons/badge-jurassic-july.svg b/assets/badge-icons/badge-jurassic-july.svg new file mode 100644 index 0000000..a7c2ebe --- /dev/null +++ b/assets/badge-icons/badge-jurassic-july.svg @@ -0,0 +1 @@ + diff --git a/assets/badge-icons/badge-machine-code.svg b/assets/badge-icons/badge-machine-code.svg new file mode 100644 index 0000000..6db24d9 --- /dev/null +++ b/assets/badge-icons/badge-machine-code.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/badge-icons/badge-mind-shifting-may.svg b/assets/badge-icons/badge-mind-shifting-may.svg new file mode 100644 index 0000000..272acdf --- /dev/null +++ b/assets/badge-icons/badge-mind-shifting-may.svg @@ -0,0 +1 @@ + diff --git a/assets/badge-icons/badge-nibbly-november.svg b/assets/badge-icons/badge-nibbly-november.svg new file mode 100644 index 0000000..2c1e3cc --- /dev/null +++ b/assets/badge-icons/badge-nibbly-november.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/badge-icons/badge-object-oriented-october.svg b/assets/badge-icons/badge-object-oriented-october.svg new file mode 100644 index 0000000..65747cf --- /dev/null +++ b/assets/badge-icons/badge-object-oriented-october.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/badge-icons/badge-polyglot.svg b/assets/badge-icons/badge-polyglot.svg new file mode 100644 index 0000000..e48a301 --- /dev/null +++ b/assets/badge-icons/badge-polyglot.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/badge-icons/badge-slimline-september.svg b/assets/badge-icons/badge-slimline-september.svg new file mode 100644 index 0000000..f0a088c --- /dev/null +++ b/assets/badge-icons/badge-slimline-september.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/badge-icons/badge-summer-of-sexps.svg b/assets/badge-icons/badge-summer-of-sexps.svg new file mode 100644 index 0000000..8167ae3 --- /dev/null +++ b/assets/badge-icons/badge-summer-of-sexps.svg @@ -0,0 +1 @@ + diff --git a/assets/badge-icons/bard.svg b/assets/badge-icons/bard.svg new file mode 100644 index 0000000..1e93607 --- /dev/null +++ b/assets/badge-icons/bard.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/assets/badge-icons/begetter.svg b/assets/badge-icons/begetter.svg new file mode 100644 index 0000000..ff06302 --- /dev/null +++ b/assets/badge-icons/begetter.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/badge-icons/chatterbox.svg b/assets/badge-icons/chatterbox.svg new file mode 100644 index 0000000..9237b7a --- /dev/null +++ b/assets/badge-icons/chatterbox.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/badge-icons/completer.svg b/assets/badge-icons/completer.svg new file mode 100644 index 0000000..843b134 --- /dev/null +++ b/assets/badge-icons/completer.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/badge-icons/conceptual.svg b/assets/badge-icons/conceptual.svg new file mode 100644 index 0000000..683a829 --- /dev/null +++ b/assets/badge-icons/conceptual.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/assets/badge-icons/contributors.svg b/assets/badge-icons/contributors.svg new file mode 100644 index 0000000..791a34b --- /dev/null +++ b/assets/badge-icons/contributors.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/badge-icons/die-unendliche-geschichte.svg b/assets/badge-icons/die-unendliche-geschichte.svg new file mode 100644 index 0000000..649f72d --- /dev/null +++ b/assets/badge-icons/die-unendliche-geschichte.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/badge-icons/discourser.svg b/assets/badge-icons/discourser.svg new file mode 100644 index 0000000..365b2d9 --- /dev/null +++ b/assets/badge-icons/discourser.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/badge-icons/editor.svg b/assets/badge-icons/editor.svg new file mode 100644 index 0000000..d911f84 --- /dev/null +++ b/assets/badge-icons/editor.svg @@ -0,0 +1,17 @@ + + + + + file-code-1 + + + + \ No newline at end of file diff --git a/assets/badge-icons/functional.svg b/assets/badge-icons/functional.svg new file mode 100644 index 0000000..d061cd8 --- /dev/null +++ b/assets/badge-icons/functional.svg @@ -0,0 +1 @@ + diff --git a/assets/badge-icons/hello-world.svg b/assets/badge-icons/hello-world.svg new file mode 100644 index 0000000..32ac6c6 --- /dev/null +++ b/assets/badge-icons/hello-world.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/badge-icons/insiders.svg b/assets/badge-icons/insiders.svg new file mode 100644 index 0000000..5be9957 --- /dev/null +++ b/assets/badge-icons/insiders.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/badge-icons/lackadaisical.svg b/assets/badge-icons/lackadaisical.svg new file mode 100644 index 0000000..d7da8cf --- /dev/null +++ b/assets/badge-icons/lackadaisical.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/badge-icons/larisa-latynina.svg b/assets/badge-icons/larisa-latynina.svg new file mode 100644 index 0000000..f9ae293 --- /dev/null +++ b/assets/badge-icons/larisa-latynina.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/badge-icons/lifetime-insiders.svg b/assets/badge-icons/lifetime-insiders.svg new file mode 100644 index 0000000..d9a1dd1 --- /dev/null +++ b/assets/badge-icons/lifetime-insiders.svg @@ -0,0 +1 @@ + diff --git a/assets/badge-icons/logo.svg b/assets/badge-icons/logo.svg new file mode 100644 index 0000000..718e457 --- /dev/null +++ b/assets/badge-icons/logo.svg @@ -0,0 +1,14 @@ + \ No newline at end of file diff --git a/assets/badge-icons/mentor.svg b/assets/badge-icons/mentor.svg new file mode 100644 index 0000000..af60ffb --- /dev/null +++ b/assets/badge-icons/mentor.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/assets/badge-icons/mentoring.svg b/assets/badge-icons/mentoring.svg new file mode 100644 index 0000000..bf477bc --- /dev/null +++ b/assets/badge-icons/mentoring.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/badge-icons/moss.svg b/assets/badge-icons/moss.svg new file mode 100644 index 0000000..d968496 --- /dev/null +++ b/assets/badge-icons/moss.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/assets/badge-icons/new-years-resolution.svg b/assets/badge-icons/new-years-resolution.svg new file mode 100644 index 0000000..5395923 --- /dev/null +++ b/assets/badge-icons/new-years-resolution.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/assets/badge-icons/paavo-nurmi.svg b/assets/badge-icons/paavo-nurmi.svg new file mode 100644 index 0000000..81917a0 --- /dev/null +++ b/assets/badge-icons/paavo-nurmi.svg @@ -0,0 +1 @@ +Olympics Torch Streamline Icon: https://streamlinehq.comolympics-torch \ No newline at end of file diff --git a/assets/badge-icons/researcher.svg b/assets/badge-icons/researcher.svg new file mode 100644 index 0000000..41c4d61 --- /dev/null +++ b/assets/badge-icons/researcher.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/badge-icons/supermentor.svg b/assets/badge-icons/supermentor.svg new file mode 100644 index 0000000..768dd22 --- /dev/null +++ b/assets/badge-icons/supermentor.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/badge-icons/supporter.svg b/assets/badge-icons/supporter.svg new file mode 100644 index 0000000..494da64 --- /dev/null +++ b/assets/badge-icons/supporter.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/badge-icons/tooling-pioneer.svg b/assets/badge-icons/tooling-pioneer.svg new file mode 100644 index 0000000..078df53 --- /dev/null +++ b/assets/badge-icons/tooling-pioneer.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/assets/badge-icons/troubleshooter.svg b/assets/badge-icons/troubleshooter.svg new file mode 100644 index 0000000..73dff00 --- /dev/null +++ b/assets/badge-icons/troubleshooter.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/badge-icons/usain-bolt.svg b/assets/badge-icons/usain-bolt.svg new file mode 100644 index 0000000..44593b3 --- /dev/null +++ b/assets/badge-icons/usain-bolt.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/badge-icons/v1.svg b/assets/badge-icons/v1.svg new file mode 100644 index 0000000..a8518d1 --- /dev/null +++ b/assets/badge-icons/v1.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/badge-icons/v2.svg b/assets/badge-icons/v2.svg new file mode 100644 index 0000000..a38b29e --- /dev/null +++ b/assets/badge-icons/v2.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/assets/badge-icons/v3-pioneer.svg b/assets/badge-icons/v3-pioneer.svg new file mode 100644 index 0000000..f8aa9ef --- /dev/null +++ b/assets/badge-icons/v3-pioneer.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/assets/badge-icons/whatever.svg b/assets/badge-icons/whatever.svg new file mode 100644 index 0000000..a9abed8 --- /dev/null +++ b/assets/badge-icons/whatever.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/medallions/common.svg b/assets/medallions/common.svg new file mode 100644 index 0000000..83bd833 --- /dev/null +++ b/assets/medallions/common.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/assets/medallions/legendary.svg b/assets/medallions/legendary.svg new file mode 100644 index 0000000..ec7c9a2 --- /dev/null +++ b/assets/medallions/legendary.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/medallions/rare.svg b/assets/medallions/rare.svg new file mode 100644 index 0000000..5118f34 --- /dev/null +++ b/assets/medallions/rare.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/medallions/ultimate.svg b/assets/medallions/ultimate.svg new file mode 100644 index 0000000..8ae9b3f --- /dev/null +++ b/assets/medallions/ultimate.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/profile-icons/authoring.svg b/assets/profile-icons/authoring.svg new file mode 100644 index 0000000..c772c21 --- /dev/null +++ b/assets/profile-icons/authoring.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/profile-icons/building.svg b/assets/profile-icons/building.svg new file mode 100644 index 0000000..dff025e --- /dev/null +++ b/assets/profile-icons/building.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/profile-icons/community-solutions.svg b/assets/profile-icons/community-solutions.svg new file mode 100644 index 0000000..30bb7c6 --- /dev/null +++ b/assets/profile-icons/community-solutions.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/profile-icons/hex.svg b/assets/profile-icons/hex.svg new file mode 100644 index 0000000..800b46d --- /dev/null +++ b/assets/profile-icons/hex.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/profile-icons/insiders.svg b/assets/profile-icons/insiders.svg new file mode 100644 index 0000000..5be9957 --- /dev/null +++ b/assets/profile-icons/insiders.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/profile-icons/lifetime-insiders.svg b/assets/profile-icons/lifetime-insiders.svg new file mode 100644 index 0000000..d9a1dd1 --- /dev/null +++ b/assets/profile-icons/lifetime-insiders.svg @@ -0,0 +1 @@ + diff --git a/assets/profile-icons/logo.svg b/assets/profile-icons/logo.svg new file mode 100644 index 0000000..718e457 --- /dev/null +++ b/assets/profile-icons/logo.svg @@ -0,0 +1,14 @@ + \ No newline at end of file diff --git a/assets/profile-icons/maintaining.svg b/assets/profile-icons/maintaining.svg new file mode 100644 index 0000000..fee967e --- /dev/null +++ b/assets/profile-icons/maintaining.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/profile-icons/mentoring.svg b/assets/profile-icons/mentoring.svg new file mode 100644 index 0000000..bf477bc --- /dev/null +++ b/assets/profile-icons/mentoring.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/profile-icons/more-horizontal.svg b/assets/profile-icons/more-horizontal.svg new file mode 100644 index 0000000..7bd5e11 --- /dev/null +++ b/assets/profile-icons/more-horizontal.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/profile-icons/reputation.svg b/assets/profile-icons/reputation.svg new file mode 100644 index 0000000..4d0018e --- /dev/null +++ b/assets/profile-icons/reputation.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/profile-icons/staff-flair.svg b/assets/profile-icons/staff-flair.svg new file mode 100644 index 0000000..ed87d17 --- /dev/null +++ b/assets/profile-icons/staff-flair.svg @@ -0,0 +1,2 @@ + + diff --git a/dev/build-fonts.sh b/dev/build-fonts.sh index 19440d2..45d68e2 100755 --- a/dev/build-fonts.sh +++ b/dev/build-fonts.sh @@ -4,22 +4,16 @@ # # dev/build-fonts.sh # -# You only need to run this to regenerate the fonts - the .woff files are -# committed, because the Lambda image build can't depend on a Python toolchain -# and nobody wants a font pipeline in the deploy path. +# The .woff files are committed, so this is only for regenerating them - the +# Lambda image build can't depend on a Python toolchain. # # Why this exists rather than just adding @fontsource/noto-* to package.json: +# satori can't read woff2 ("Unsupported OpenType signature wOF2") and +# fontsource's plain .woff copies of a full CJK face are an order of magnitude +# bigger, so the ~125 numbered subsets it ships per weight are merged back into +# one face here and re-cut to the ranges below. # -# - satori cannot read woff2 ("Unsupported OpenType signature wOF2"). It takes -# woff, ttf and otf only. The woff2 files are the small ones; fontsource's -# .woff copies of a full CJK face are an order of magnitude bigger. -# - fontsource ships CJK split across ~125 numbered subsets per weight, and -# the numbering is not by frequency - the common CJK block alone spans over -# a hundred of them. Registering that many faces with satori is not viable, -# so they get merged back into one face and re-subset to the ranges we -# actually need. -# -# Sizes it produces (see the table in satori_renderer.js): +# What it produces: # # cjk-400.woff 2.6M kana, CJK punctuation, fullwidth forms, # CJK Unified Ideographs (covers JA and ZH) diff --git a/dev/fixtures/profile-minimal.json b/dev/fixtures/profile-minimal.json new file mode 100644 index 0000000..f411f8d --- /dev/null +++ b/dev/fixtures/profile-minimal.json @@ -0,0 +1,42 @@ +{ + "header": { + "handle": "newcomer", + "name": null, + "avatar_url": null, + "reputation": 12, + "badges": [], + "tags": [] + }, + "categories": [ + { + "id": "publishing", + "metric": "3 solutions published", + "reputation": 12 + }, + { + "id": "mentoring", + "metric": null, + "reputation": 0 + }, + { + "id": "authoring", + "metric": null, + "reputation": 0 + }, + { + "id": "building", + "metric": null, + "reputation": 0 + }, + { + "id": "maintaining", + "metric": null, + "reputation": 0 + }, + { + "id": "other", + "metric": null, + "reputation": 0 + } + ] +} diff --git a/dev/fixtures/profile-mixed-scripts.json b/dev/fixtures/profile-mixed-scripts.json new file mode 100644 index 0000000..3b76fed --- /dev/null +++ b/dev/fixtures/profile-mixed-scripts.json @@ -0,0 +1,62 @@ +{ + "header": { + "handle": "山田太郎", + "name": "Ямада Таро 🎉", + "avatar_url": null, + "reputation": 4821, + "badges": [ + { + "slug": "architect", + "rarity": "legendary" + }, + { + "slug": "completer", + "rarity": "ultimate" + }, + { + "slug": "mentor", + "rarity": "rare" + }, + { + "slug": "member", + "rarity": "common" + } + ], + "tags": [ + "Maintainer", + "Insider" + ] + }, + "categories": [ + { + "id": "publishing", + "metric": "42 solutions published", + "reputation": 310 + }, + { + "id": "mentoring", + "metric": "88 students mentored", + "reputation": 1902 + }, + { + "id": "authoring", + "metric": "12 exercises/articles contributed", + "reputation": 640 + }, + { + "id": "building", + "metric": "31 PRs accepted", + "reputation": 1200 + }, + { + "id": "maintaining", + "metric": "9 PRs reviewed", + "reputation": 769 + }, + { + "id": "other", + "metric": null, + "reputation": 0 + } + ] +} diff --git a/dev/fixtures/profile.json b/dev/fixtures/profile.json new file mode 100644 index 0000000..04c7cde --- /dev/null +++ b/dev/fixtures/profile.json @@ -0,0 +1,25 @@ +{ + "header": { + "handle": "iHiD", + "name": "Jeremy Walker", + "avatar_url": null, + "reputation": 65110, + "flair": "founder", + "badges": [ + { "icon": "moss", "rarity": "legendary" }, + { "icon": "architect", "rarity": "legendary" }, + { "icon": "begetter", "rarity": "legendary" }, + { "icon": "supermentor", "rarity": "legendary" }, + { "icon": "v3-pioneer", "rarity": "legendary" } + ], + "tags": ["Exercism Founder"] + }, + "categories": [ + { "id": "publishing", "metric": "81 solutions published", "reputation": 93 }, + { "id": "mentoring", "metric": "179 students mentored", "reputation": 852 }, + { "id": "authoring", "metric": "403 exercises/articles contributed", "reputation": 4057 }, + { "id": "building", "metric": "2,659 PRs accepted", "reputation": 31894 }, + { "id": "maintaining", "metric": "7,171 PRs reviewed", "reputation": 28214 }, + { "id": "other", "metric": null, "reputation": 0 } + ] +} diff --git a/dev/render.js b/dev/render.js index 83435ce..9b96571 100644 --- a/dev/render.js +++ b/dev/render.js @@ -5,27 +5,21 @@ // // node dev/render.js # committed fixture // node dev/render.js --fixture path/to.json # your own payload -// node dev/render.js --fixture dev/fixtures/solution-with-avatar.json -// node dev/render.js --fixture dev/fixtures/solution-go-tabs.json -// node dev/render.js --fixture dev/fixtures/solution-mixed-scripts.json -// -// The default fixture has no avatar, which is a real case the renderer handles; -// solution-with-avatar.json covers the other one. Its avatar is an inlined data -// URI rather than a link to the avatars host, so rendering stays offline - -// satori fetches image sources over the network at render time. +// node dev/render.js --fixture dev/fixtures/profile.json +// node dev/render.js --out /tmp/mine.png +// node dev/render.js --url https://internal.exercism.org/spi/solution_image_data/ruby/bob/ihid // -// solution-go-tabs.json is tab-indented Go at indent_size 4. Space-indented -// tracks look the same whatever indent_size says, so it takes a track that -// actually ships literal tabs to see that value being honoured. +// The payload shape says which renderer to use, so profile fixtures pick the +// profile renderer automatically. What each fixture is for: // -// solution-mixed-scripts.json is Japanese, Chinese, Korean, Cyrillic, Greek, -// Vietnamese and emoji in one file, in code and in the footer handle. All of -// the CJK and emoji rendered as tofu boxes until the fallback faces went in, -// and satori doesn't error on a missing glyph, so this is the one to look at -// after touching anything font-related. fonts.test.js checks the same ground -// automatically; this is for seeing it. -// node dev/render.js --url https://internal.exercism.org/spi/solution_image_data/ruby/bob/ihid -// node dev/render.js --out /tmp/mine.png +// profile.json a heavily-decorated account +// profile-minimal.json a new one: no badges, no name, empty categories +// profile-mixed-scripts.json a CJK handle and a Cyrillic name +// solution.json the default; no avatar, which is a real case +// solution-with-avatar.json avatar inlined as a data URI, to stay offline +// solution-go-tabs.json tab-indented Go, the only way to see indent_size +// solution-mixed-scripts.json seven scripts and emoji, in code and in the +// footer - look at this after any font change // // --url takes the data endpoint itself. internal.exercism.org only resolves // from inside the VPC, so locally point it at your own Rails: @@ -65,9 +59,12 @@ async function main() { // Stub the fetch the renderer would do, so one code path serves both modes. global.fetch = async () => ({ ok: true, json: async () => data }); + // Taken from the payload rather than a flag, so there's nothing to get wrong. + const kind = data.categories || data.header ? "profile" : "solution"; + const started = Date.now(); - const { generate } = require("../satori_renderer"); - const { body, contentType } = await generate({ dataUrl: url || "fixture://solution" }); + const { generate } = require(kind === "profile" ? "../profile_renderer" : "../satori_renderer"); + const { body, contentType } = await generate({ dataUrl: url || `fixture://${kind}` }); const elapsed = Date.now() - started; fs.writeFileSync(out, body); diff --git a/dev/sync-badge-icons.sh b/dev/sync-badge-icons.sh new file mode 100755 index 0000000..49be882 --- /dev/null +++ b/dev/sync-badge-icons.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# +# Copies the badge icons out of the website repo into assets/badge-icons/. +# +# dev/sync-badge-icons.sh [path-to-website-repo] +# +# The icons are committed, so this only needs running when a badge is added or +# its icon changes. They're vendored because satori resolves image sources over +# the network, and a profile can show five badges. +# +# Which icon a badge uses is the third argument to `seed` in +# app/models/badges/*_badge.rb and is NOT derivable from the class name - +# RookieBadge uses `editor` - so that mapping is written to badge-icons.json. +# +set -euo pipefail + +cd "$(dirname "$0")/.." +website="${1:-../website}" +out="assets/badge-icons" + +if [ ! -d "$website/app/models/badges" ]; then + echo "Can't find the website repo at '$website'." >&2 + echo "Pass its path: dev/sync-badge-icons.sh ../website" >&2 + exit 1 +fi + +mkdir -p "$out" +rm -f "$out"/*.svg + +WEBSITE="$website" OUT="$out" node <<'NODE' +const fs = require("fs"); +const path = require("path"); + +const website = process.env.WEBSITE; +const out = process.env.OUT; +const badgeDir = path.join(website, "app/models/badges"); +const iconDir = path.join(website, "app/images/icons"); + +const badges = {}; +const missing = []; + +for (const file of fs.readdirSync(badgeDir).filter((f) => f.endsWith("_badge.rb"))) { + const source = fs.readFileSync(path.join(badgeDir, file), "utf8"); + // Anchored on the rarity: names can contain apostrophes, and the icon is a + // string in some badges and a symbol in others. + const match = source.match( + /seed\s+[\s\S]*?,\s*:(common|rare|ultimate|legendary),\s*(?:["']([\w-]+)["']|:([\w-]+))/ + ); + + if (!match) { + missing.push(file); + continue; + } + + const slug = file.replace(/_badge\.rb$/, ""); + const icon = match[2] || match[3]; + badges[slug] = { rarity: match[1], icon }; + + const source_path = path.join(iconDir, `${icon}.svg`); + if (fs.existsSync(source_path)) { + fs.copyFileSync(source_path, path.join(out, `${icon}.svg`)); + } else { + missing.push(`${slug} -> ${icon}.svg`); + } +} + +fs.writeFileSync(path.join(out, "badge-icons.json"), `${JSON.stringify(badges, null, 2)}\n`); + +const count = Object.keys(badges).length; +console.log(`${count} badges, ${new Set(Object.values(badges).map((b) => b.icon)).size} distinct icons`); +if (missing.length) { + console.error(`Could not resolve: ${missing.join(", ")}`); + process.exit(1); +} +NODE + +du -sh "$out" diff --git a/dev/sync-profile-icons.sh b/dev/sync-profile-icons.sh new file mode 100755 index 0000000..f9c9f44 --- /dev/null +++ b/dev/sync-profile-icons.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# +# Copies the icons the profile image needs out of the website repo into +# assets/profile-icons/. +# +# dev/sync-profile-icons.sh [path-to-website-repo] +# +# Committed, so this only needs re-running when an icon changes upstream. Same +# reasoning as dev/sync-badge-icons.sh: satori resolves image sources over the +# network, so anything unvendored is another way for an image to hang. +# +# What's here and where it comes from: +# +# reputation ViewComponents::Reputation - the shield in the rep pill +# staff-flair HandleWithFlair ICONS[:founder] and [:staff] +# insiders HandleWithFlair ICONS[:insider] +# lifetime-insiders HandleWithFlair ICONS[:lifetime_insider] +# logo graphical_icon :logo - the {~} in the founder tag +# community-solutions \ +# mentoring | +# authoring | CATEGORY_ICONS in ContributionsSummary.tsx, one per +# building | category, drawn inside the hexagon +# maintaining | +# more-horizontal / +# +# The hexagon those sit in isn't an icon file but --backgroundImageHex, a base64 +# data URI in app/css/ui-kit/colors.css, so it's extracted below. +# +set -euo pipefail + +cd "$(dirname "$0")/.." +website="${1:-../website}" +out="assets/profile-icons" + +if [ ! -d "$website/app/images/icons" ]; then + echo "Can't find the website repo at '$website'." >&2 + echo "Pass its path: dev/sync-profile-icons.sh ../website" >&2 + exit 1 +fi + +mkdir -p "$out" +rm -f "$out"/*.svg + +WEBSITE="$website" OUT="$out" node <<'NODE' +const fs = require("fs"); +const path = require("path"); + +const website = process.env.WEBSITE; +const out = process.env.OUT; +const iconDir = path.join(website, "app/images/icons"); + +const icons = [ + "reputation", + "staff-flair", + "insiders", + "lifetime-insiders", + "logo", + "community-solutions", + "mentoring", + "authoring", + "building", + "maintaining", + "more-horizontal" +]; + +const missing = []; + +for (const icon of icons) { + const source = path.join(iconDir, `${icon}.svg`); + + if (fs.existsSync(source)) { + fs.copyFileSync(source, path.join(out, `${icon}.svg`)); + } else { + missing.push(`${icon}.svg`); + } +} + +// Declared twice: light theme first, then .theme-dark. The image renders dark, +// and the light one is a white hexagon that would be a blob against the card. +const colors = fs.readFileSync(path.join(website, "app/css/ui-kit/colors.css"), "utf8"); +const declarations = colors + .split("\n") + .filter((line) => line.includes("--backgroundImageHex")); + +if (declarations.length < 2) { + missing.push("--backgroundImageHex (dark theme)"); +} else { + const encoded = declarations[1].match(/base64,([^"]+)/); + + if (encoded) { + fs.writeFileSync(path.join(out, "hex.svg"), Buffer.from(encoded[1], "base64").toString("utf8")); + } else { + missing.push("--backgroundImageHex (not base64)"); + } +} + +console.log(`${fs.readdirSync(out).filter((f) => f.endsWith(".svg")).length} icons`); +if (missing.length) { + console.error(`Could not resolve: ${missing.join(", ")}`); + process.exit(1); +} +NODE + +du -sh "$out" diff --git a/fonts.test.js b/fonts.test.js index 8034065..7c4441a 100644 --- a/fonts.test.js +++ b/fonts.test.js @@ -3,21 +3,12 @@ const assert = require("node:assert"); const satori = require("satori").default; const { fonts } = require("./satori_renderer"); -// satori never errors on a missing glyph. It draws a tofu box and returns a -// perfectly valid SVG, which is what makes this worth testing: a bad image is -// written through to S3 and served for that URL indefinitely, and nobody finds -// out until a user reports it. -// -// Detecting that is fiddlier than it looks. The obvious check - "did it draw -// anything?" - passes on tofu, because a box *is* geometry: five tofu boxes -// emit more path data than five narrow latin letters. Counting subpaths fails -// for the same reason. -// -// What separates them is variety. Tofu is one box shape repeated, so a string -// of n distinct characters collapses to a handful of distinct shapes however -// long it gets. Real text draws a different outline per character. So: strip -// the coordinates from each subpath, and count how many distinct shapes are -// left. +// satori draws a missing glyph as a tofu box and returns a valid SVG, so a bad +// render is written through to S3 and served indefinitely. Detecting it needs +// variety rather than volume - "did it draw anything?" passes on tofu, since a +// box is geometry too - so this strips the coordinates off each subpath and +// counts the distinct shapes left. Tofu is one shape repeated however long the +// string; real text draws a different outline per character. async function distinctShapes(text, style = {}) { const svg = await satori( { @@ -36,29 +27,25 @@ async function distinctShapes(text, style = {}) { paths .split(/(?=M)/) .filter((subpath) => subpath.trim()) - // Position and size are what differ between two copies of the same box; - // removing every number leaves the shape itself. + // Two copies of the same box differ only in position and size. .map((subpath) => subpath.replace(/-?\d+(\.\d+)?/g, "#")) ).size; } -// Every sample below is five distinct characters. Rendered properly that gives -// roughly five distinct shapes; tofu gives about four however many characters -// it is handed, so this sits above tofu and below correct output. Some scripts -// legitimately land lower than their character count - Hangul syllables share -// jamo components between them - so this isn't "one shape per character". +// Every sample below is five distinct characters, which sits above what tofu +// can produce and below correct output. Not one shape per character: some +// scripts legitimately land lower, as Hangul syllables share jamo. const SAMPLE_LENGTH = 5; const TOFU_CEILING = 4; -// A private-use character no face will ever cover, so this is what guaranteed -// tofu looks like. If a font change ever made this pass, the test above it -// would be measuring nothing. +// Private-use characters no face will ever cover - guaranteed tofu. If a font +// change made this pass, every test below it would be measuring nothing. test("tofu stays below the threshold, so the other tests mean something", async () => { assert.ok(await distinctShapes("") <= TOFU_CEILING); }); -// The scripts Source Code Pro already covered. They'd have passed before the -// CJK work; they're here so a font change can't quietly drop them. +// The scripts Source Code Pro already covered, pinned so a font change can't +// quietly drop them. for (const [script, sample] of [ ["latin", "abcde"], ["cyrillic", "Привет"], @@ -70,9 +57,7 @@ for (const [script, sample] of [ }); } -// The scripts from the issue. Source Code Pro has no glyphs for any of these in -// any subset it ships, so every one of them tofud before the fallback faces -// were added. +// The scripts from the issue - all tofu before the fallback faces went in. for (const [script, sample] of [ ["japanese kana", "こんにちは"], ["japanese ideographs", "世界日本語"], @@ -86,11 +71,8 @@ for (const [script, sample] of [ }); } -// The trap documented above monoFaces(): satori matches fallback candidates on -// style as well as family, so registering the fallbacks under a single name -// lets an italic span match the primary family and stop the search - tofuing -// italic CJK while upright CJK looks fine. Comments and quotes are italic in -// every theme, and CJK in a comment is exactly what produces this. +// The naming trap documented above MONO_SUBSETS tofus italic CJK while upright +// CJK looks fine, and comments are italic in every theme. test("renders CJK in italic, not just upright", async () => { assert.ok(await distinctShapes("日本語漢字", { fontStyle: "italic" }) > TOFU_CEILING); }); @@ -99,9 +81,8 @@ test("renders CJK in bold", async () => { assert.ok(await distinctShapes("日本語漢字", { fontWeight: 600 }) > TOFU_CEILING); }); -// The footer is Poppins, not the mono family, and the issue calls this out -// specifically: a handle containing CJK rendered as a row of boxes next to the -// avatar. +// The footer is Poppins, not the mono family, and a CJK handle rendered as a +// row of boxes next to the avatar. test("renders CJK in the footer family", async () => { assert.ok(await distinctShapes("山田太郎様", { fontFamily: "Poppins" }) > TOFU_CEILING); }); diff --git a/index.js b/index.js index 2f71298..0218cdb 100644 --- a/index.js +++ b/index.js @@ -8,38 +8,33 @@ const { PutObjectCommand, } = require("@aws-sdk/client-s3"); const satoriRenderer = require("./satori_renderer"); +const profileRenderer = require("./profile_renderer"); const imagePath = "/tmp/screenshot.jpg"; const baseUrl = "https://exercism.org"; -// The satori renderer fetches its data over the internal ALB rather than the -// public site, so it never leaves the VPC and doesn't depend on this lambda's -// NAT address being allowlisted in Cloudflare. The Chrome path still uses the -// public site: it's screenshotting a rendered page, not reading a payload. +// The satori renderer reads its payload over the internal ALB, so it never +// leaves the VPC. The Chrome path still uses the public site: it's +// screenshotting a rendered page, not reading a payload. const internalBaseUrl = process.env.INTERNAL_BASE_URL || "https://internal.exercism.org"; -// Generating an image costs a few seconds of headless Chrome at 2GB, so we only -// ever want to pay for it once per distinct URL. CDN edge caches can't give us -// that on their own: they're per-PoP, they evict the long tail (most images are -// fetched a handful of times ever), and a flood of distinct URLs misses them -// entirely. Writing through to S3 makes the cost a function of how many images -// exist rather than how many times they're requested. +// Writing through to S3 makes the cost of generating an image a function of how +// many exist rather than how many times they're requested - CDN edge caches are +// per-PoP and evict the long tail, which is most of these. const bucket = process.env.IMAGE_BUCKET || "exercism-v3-assets"; const keyPrefix = process.env.IMAGE_KEY_PREFIX || "generated-images"; const s3 = new S3Client({}); -// These must stay comfortably under the Lambda's 20s timeout. puppeteer -// defaults both to 30s, which is longer, so before this a render that hung -// burned the full 20s at 2GB rather than failing fast. +// Both must stay under the Lambda's 20s timeout; puppeteer's own defaults are +// 30s, so a hung render burned the full 20s at 2GB rather than failing fast. const navigationTimeout = parseInt(process.env.NAVIGATION_TIMEOUT_MS || "6000", 10); const selectorTimeout = parseInt(process.env.SELECTOR_TIMEOUT_MS || "6000", 10); const legacyMaxAge = 86400; -// Solutions can be drawn without a browser, which is far cheaper. Off by -// default so it can be enabled once its output has been eyeballed against the -// Chrome path; profiles have no satori renderer yet and always use Chrome. +// Off by default so the browserless output can be eyeballed against the Chrome +// path before it's enabled. const satoriEnabled = process.env.RENDERER === "satori"; const solutionRegex = /^\/tracks\/(?.+?)\/exercises\/(?.+?)\/solutions\/(?.+?)(?:-\d{10})?\.jpg$/; @@ -66,6 +61,7 @@ function rawPathToScreenshotData(rawPath) { return { kind: "profile", url: `${baseUrl}/images/profiles/${user_handle}`, + dataUrl: `${internalBaseUrl}/spi/profile_image_data/${user_handle}`, imageSelector: "#image-content", waitForSelector: "#image-content #contributions-chart", }; @@ -119,8 +115,8 @@ async function fetchFromS3(key, { isTimestamped }) { new GetObjectCommand({ Bucket: bucket, Key: key }) ); - // Legacy URLs point at content that can change, so a stored copy is only - // good for as long as we'd have let a CDN hold onto it. + // Legacy URLs point at mutable content, so a stored copy is only good for + // as long as we'd have let a CDN hold onto it. if (!isTimestamped) { const age = (Date.now() - object.LastModified.getTime()) / 1000; if (age > legacyMaxAge) return null; @@ -128,8 +124,7 @@ async function fetchFromS3(key, { isTimestamped }) { return { body: Buffer.from(await object.Body.transformToByteArray()), - // Stored per object, so images cached before the renderer changed keep - // being served as whatever they were written as. + // Per object, so images cached before the renderer changed keep serving. contentType: object.ContentType || "image/jpg", }; } catch (err) { @@ -170,9 +165,8 @@ async function generateImage({ url, imageSelector, waitForSelector }) { ], }); - // Now that a render can fail rather than take the whole container down with - // it, the browser has to be closed on the way out or it leaks into the next - // warm invocation. + // Closed on the way out, or a failed render leaks it into the next warm + // invocation. try { const page = await browser.newPage(); page.setDefaultNavigationTimeout(navigationTimeout); @@ -193,8 +187,13 @@ async function generateImage({ url, imageSelector, waitForSelector }) { } } +const satoriRenderers = { + solution: satoriRenderer.generate, + profile: profileRenderer.generate, +}; + function rendererFor(screenshotData) { - if (satoriEnabled && screenshotData.kind === "solution") return satoriRenderer.generate; + if (satoriEnabled) return satoriRenderers[screenshotData.kind] || generateImage; return generateImage; } diff --git a/profile_renderer.js b/profile_renderer.js new file mode 100644 index 0000000..0f468b1 --- /dev/null +++ b/profile_renderer.js @@ -0,0 +1,632 @@ +const fs = require("fs"); +const path = require("path"); +const satori = require("satori").default; +const { Resvg } = require("@resvg/resvg-js"); +const { fonts } = require("./satori_renderer"); + +// Draws the profile share image without a browser, the same way +// satori_renderer.js draws solutions. The Chart.js radar chart the page uses is +// redrawn as inline SVG below, since satori has no canvas. + +const SCALE = 2; +const WIDTH = 800 * SCALE; + +// app/css/ui-kit/colors.css, theme-dark +const BACKGROUND = "#211D2F"; +const HEADER_BACKGROUND = "#302b42"; +const BORDER = "#433f56"; +const TEXT_STRONG = "#ffffff"; +const TEXT_MUTED = "#f0f3f9"; +const TEXT_DIM = "#a9a6bd"; +const TEXT_NAME = "#cbc9d9"; + +// The .border-gradient the reputation pill and the founder tag share. +const GRADIENT_FROM = "#2200ff"; +const GRADIENT_TO = "#9e00ff"; +const PILL_BACKGROUND = "#130b43"; +const TAG_BACKGROUND = "#191525"; +// --chartBorderColor under theme-dark. The light-theme value disappears +// against the dark card. +const CHART_BORDER = "#6a6781"; +// The gradient Chart.js builds in createBluePurpleGradient(), bottom to top. +const CHART_FROM = "rgb(34, 0, 255)"; +const CHART_TO = "rgb(158, 0, 255)"; + +const HEADER_PADDING = 32 * SCALE; +const AVATAR_SIZE = 80 * SCALE; +// The medallion SVG pads its 64-unit artwork out to 88 to give the glow room, +// so the is scaled by that ratio to leave the hexagon itself at 40px. +const MEDALLION_ARTWORK = 64; +const MEDALLION_CANVAS = 88; +const BADGE_SIZE = 40 * SCALE * (MEDALLION_CANVAS / MEDALLION_ARTWORK); +// .ml-8 is measured between the hexagons, and the glow padding above already +// contributes more than that, so the shortfall goes back as a negative margin. +const BADGE_GAP = 8 * SCALE - (BADGE_SIZE - 40 * SCALE); + +// The order ContributionsSummary.tsx lists them in. The chart's axes follow it, +// so reordering here silently rotates the polygon. +const CATEGORIES = ["publishing", "mentoring", "authoring", "building", "maintaining", "other"]; + +const CATEGORY_TITLES = { + publishing: "Publishing", + mentoring: "Mentoring", + authoring: "Authoring", + building: "Building", + maintaining: "Maintaining", + other: "Other" +}; + +let watermarkCache; +function watermark() { + watermarkCache ||= `data:image/png;base64,${fs.readFileSync(path.join(__dirname, "assets", "watermark.png")).toString("base64")}`; + + return watermarkCache; +} + +const el = (type, props, ...children) => ({ + type, + props: { ...props, children: children.length > 1 ? children : children[0] } +}); + +// --------------------------------------------------------------------------- +// The radar chart +// +// Two details are carried over from use-chart.ts rather than invented, because +// without them the shape comes out wrong: padReputation() adds total/8 to every +// value so an empty category still gets a short arm, and Chart.js puts the +// first axis at 12 o'clock, -90 degrees from where atan2 would start. +// --------------------------------------------------------------------------- + +// The SVG's own coordinate space. The radius stops short of the half-width so +// the outermost ring and its vertex dots aren't clipped. +const CHART_SIZE = 520; +const CHART_RADIUS = 232; +const CHART_RINGS = 5; + +// Sized to the space left of the category list rather than to the website's own +// canvas, which is measured against a full-width page. +const CHART_RENDER = 400 * SCALE; + +// Chart.js's line widths are CSS pixels, so they're converted into the SVG's +// own units - otherwise the web gets fainter the larger the chart is drawn. +const CHART_UNIT = CHART_SIZE / (CHART_RENDER / SCALE); +const CHART_LINE = CHART_UNIT.toFixed(3); +const CHART_STROKE = (3 * CHART_UNIT).toFixed(3); +const CHART_DOT = (5 * CHART_UNIT).toFixed(3); + +const padReputation = (reputation) => { + const min = reputation.reduce((sum, value) => sum + value, 0) / 8; + + return reputation.map((value) => min + value); +}; + +const vertex = (index, count, radius) => { + const angle = (Math.PI * 2 * index) / count - Math.PI / 2; + + return [ + CHART_SIZE / 2 + Math.cos(angle) * radius, + CHART_SIZE / 2 + Math.sin(angle) * radius + ]; +}; + +const points = (list) => list.map(([x, y]) => `${x.toFixed(2)},${y.toFixed(2)}`).join(" "); + +function radarChartSvg(reputation) { + const values = padReputation(reputation); + const count = values.length; + // Chart.js scales to the largest value, so the widest category always touches + // the outer ring. + const max = Math.max(...values); + const plotted = values.map((value, index) => + vertex(index, count, max === 0 ? 0 : (value / max) * CHART_RADIUS) + ); + + const web = Array.from({ length: CHART_RINGS }, (_, ring) => { + const radius = (CHART_RADIUS * (ring + 1)) / CHART_RINGS; + const ringPoints = Array.from({ length: count }, (_, index) => vertex(index, count, radius)); + + return ``; + }).join(""); + + const spokes = Array.from({ length: count }, (_, index) => { + const [x, y] = vertex(index, count, CHART_RADIUS); + + return ``; + }).join(""); + + const dots = plotted + .map(([x, y]) => ``) + .join(""); + + return ` + + + + + + + + + + + ${web}${spokes} + + ${dots} +`; +} + +const svgDataUri = (svg) => `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`; + +// --------------------------------------------------------------------------- +// Vendored icons +// +// Everything drawn here that isn't text is an SVG lifted out of the website by +// dev/sync-profile-icons.sh. Nothing is fetched at render time: satori resolves +// image sources over the network, which is a dozen more ways for a profile +// image to hang. +// --------------------------------------------------------------------------- + +const iconCache = new Map(); + +function icon(name) { + if (!iconCache.has(name)) { + iconCache.set( + name, + fs.readFileSync(path.join(__dirname, "assets", "profile-icons", `${name}.svg`), "utf8") + ); + } + + return iconCache.get(name); +} + +const viewBoxOf = (source) => { + const explicit = source.match(/viewBox="([^"]*)"/); + if (explicit) return explicit[1].split(/[\s,]+/).map(Number); + + // A couple of the icons size themselves with width/height instead. + const width = source.match(/\bwidth="([\d.]+)"/); + const height = source.match(/\bheight="([\d.]+)"/); + + return [0, 0, width ? Number(width[1]) : 24, height ? Number(height[1]) : 24]; +}; + +const innerOf = (source) => + source.replace(/^[\s\S]*?]*>/, "").replace(/<\/svg>\s*$/, ""); + +// The website recolours icons with a CSS filter; there's none here, so fills and +// strokes are rewritten directly. fill="none" is structural and has to survive. +const recolour = (markup, colour) => + markup + .replace(/fill="(?!none)[^"]*"/g, `fill="${colour}"`) + .replace(/stroke="(?!none)[^"]*"/g, `stroke="${colour}"`); + +// Scales an icon to fit a box of `size` units and centres it. The root 's +// fill is re-applied to the group replacing it, since most of these icons leave +// their paths inheriting it and would otherwise fall back to black. +function placeIcon(name, size, colour, { scale = 1 } = {}) { + const source = icon(name); + const [minX, minY, width, height] = viewBoxOf(source); + const factor = (size / Math.max(width, height)) * scale; + const markup = colour ? recolour(innerOf(source), colour) : innerOf(source); + const rootFill = source.match(/]*\sfill="([^"]*)"/); + const inherited = rootFill ? ` fill="${rootFill[1] === "none" ? "none" : colour || rootFill[1]}"` : ""; + + return `${markup}`; +} + +// The hexagon is --backgroundImageHex from the dark theme; the glyph sits at +// 37% of it, the size .c-icon.--hex gives it in contributions-summary.css. +const HEX_ICON_SIZE = 48 * SCALE; +const HEX_GLYPH_RATIO = 0.37; + +// Sized so the longest metric in practice wraps rather than running up against +// the reputation column beside it. +const METRIC_WIDTH = 230 * SCALE; + +const CATEGORY_ICONS = { + publishing: "community-solutions", + mentoring: "mentoring", + authoring: "authoring", + building: "building", + maintaining: "maintaining", + other: "more-horizontal" +}; + +function hexIconSvg(id) { + const hex = icon("hex"); + const [, , size] = viewBoxOf(hex); + const glyphBox = size * HEX_GLYPH_RATIO; + const offset = (size - glyphBox) / 2; + const glyph = placeIcon(CATEGORY_ICONS[id] || CATEGORY_ICONS.other, glyphBox, TEXT_MUTED); + + return ` + ${innerOf(hex)} + ${glyph} +`; +} + +// A standalone icon rendered into its own , since satori draws inline SVG +// as an image source rather than as markup. +const iconDataUri = (name, size, colour, options) => + svgDataUri( + `${placeIcon(name, size, colour, options)}` + ); + +// The drop-shadow and text-#{rarity}BadgeFill each rarity gets in +// app/css/components/badge.css. These SVGs go to resvg rather than satori, and +// resvg supports feGaussianBlur, so the glow is real rather than faked. +const RARITY_GLOWS = { + common: { colour: "rgb(200, 200, 200)", opacity: 0.6, fill: "#505359" }, + rare: { colour: "rgb(219, 240, 255)", opacity: 0.6, fill: "#00144B" }, + ultimate: { colour: "rgb(255, 230, 0)", opacity: 0.6, fill: "#560000" }, + legendary: { colour: "rgb(255, 0, 0)", opacity: 0.8, fill: "#4B0000" } +}; + +// The icon a badge uses isn't derivable from its name - ContributorBadge uses +// `contributors` - so the mapping is vendored by dev/sync-badge-icons.sh. +let badgeIconIndex; +function badgeIcons() { + badgeIconIndex ||= JSON.parse( + fs.readFileSync(path.join(__dirname, "assets", "badge-icons", "badge-icons.json"), "utf8") + ); + + return badgeIconIndex; +} + +// .c-badge-medallion .c-icon is 45% of the medallion. +const BADGE_ICON_RATIO = 0.45; + +// Normalises an icon into the medallion's coordinate space and recolours it to +// the rarity's fill. The payload names the icon directly so a new badge draws +// without a re-sync; the slug is only accepted for payloads that predate that. +function badgeIconMarkup({ icon, slug }, fill) { + const name = icon || badgeIcons()[slug]?.icon; + if (!name) return ""; + + const file = path.join(__dirname, "assets", "badge-icons", `${name}.svg`); + if (!fs.existsSync(file)) return ""; + + const source = fs.readFileSync(file, "utf8"); + // The monthly challenge badges carry no viewBox at all, so this leans on + // viewBoxOf's width/height fallback rather than reading the attribute. + const [minX, minY, width, height] = viewBoxOf(source); + const size = MEDALLION_ARTWORK * BADGE_ICON_RATIO; + const scale = size / Math.max(width, height); + // Centred on its own aspect, so a tall icon isn't pushed off toward a corner. + const offsetX = (MEDALLION_ARTWORK - width * scale) / 2; + const offsetY = (MEDALLION_ARTWORK - height * scale) / 2; + + const inner = recolour(innerOf(source), fill); + const rootFill = source.match(/]*\sfill="([^"]*)"/); + const inherited = rootFill ? ` fill="${rootFill[1] === "none" ? "none" : fill}"` : ""; + + // The trailing translate undoes a viewBox with a non-zero origin. + return `${inner}`; +} + +const medallionCache = new Map(); + +function medallionSvg({ rarity, icon, slug }) { + const key = RARITY_GLOWS[rarity] ? rarity : "common"; + // Keyed on both, since a payload may carry only one of them. + const cacheKey = `${key}:${icon || ""}:${slug || ""}`; + + if (!medallionCache.has(cacheKey)) { + const source = fs.readFileSync(path.join(__dirname, "assets", "medallions", `${key}.svg`), "utf8"); + const glow = RARITY_GLOWS[key]; + + const inner = source + .replace(/^]*>/, "") + .replace(/<\/svg>\s*$/, ""); + + // The 64-unit artwork is centred in an 88-unit canvas so the blur has + // somewhere to fall off instead of being clipped at the edge. + medallionCache.set( + cacheKey, + ` + + + + + + + + ${inner.replace(/fill="(?!none)[^"]*"/g, `fill="${glow.colour}"`)} + + ${inner} + ${badgeIconMarkup({ icon, slug }, glow.fill)} + +` + ); + } + + return medallionCache.get(cacheKey); +} + +// --------------------------------------------------------------------------- +// Layout +// --------------------------------------------------------------------------- + +// HandleWithFlair ICONS, at the `xlarge` size SIZES puts at 28px. +const FLAIR_ICONS = { + insider: "insiders", + lifetime_insider: "lifetime-insiders", + founder: "staff-flair", + staff: "staff-flair" +}; + +const FLAIR_SIZE = 28; + +// The founder tag is the only one the HAML draws with an icon of its own. Tags +// arrive as plain strings, so it's recognised by the title config/locales sets. +const TAG_ICONS = { + "Exercism Founder": "logo" +}; + +function header({ handle, name, avatar_url, reputation, flair, badges = [], tags = [] }) { + const avatar = avatar_url + ? el("img", { + src: avatar_url, + width: AVATAR_SIZE, + height: AVATAR_SIZE, + style: { borderRadius: AVATAR_SIZE, marginRight: 32 * SCALE } + }) + : el("div", { + style: { + display: "flex", + width: AVATAR_SIZE, + height: AVATAR_SIZE, + marginRight: 32 * SCALE, + borderRadius: AVATAR_SIZE, + backgroundColor: BORDER + } + }); + + // .c-primary-reputation. satori has no background-clip, so the gradient + // border is a gradient-filled wrapper with the pill's background inset in it. + const reputationPill = el("div", { + style: { + display: "flex", + padding: 3 * SCALE, + borderRadius: 100, + backgroundImage: `linear-gradient(${GRADIENT_TO}, ${GRADIENT_FROM})` + } + }, + el("div", { + style: { + display: "flex", + alignItems: "center", + paddingLeft: 16 * SCALE, + paddingRight: 16 * SCALE, + paddingTop: 2 * SCALE, + paddingBottom: 2 * SCALE, + borderRadius: 100, + backgroundColor: PILL_BACKGROUND, + // Hard-coded in the CSS too, so the number stays legible in any theme. + color: "#fbfcfe", + fontSize: 20 * SCALE, + fontWeight: 600 + } + }, + el("img", { + src: iconDataUri("reputation", 24 * SCALE, "#fbcc4c"), + width: 24 * SCALE, + height: 24 * SCALE, + style: { marginRight: 8 * SCALE } + }), + Number(reputation).toLocaleString("en-US") + ) + ); + + const flairIcon = FLAIR_ICONS[flair] + ? el("img", { + // Not recoloured: every flair icon carries its own colours. + src: iconDataUri(FLAIR_ICONS[flair], FLAIR_SIZE * SCALE), + width: FLAIR_SIZE * SCALE, + height: FLAIR_SIZE * SCALE, + style: { marginLeft: Math.ceil(FLAIR_SIZE / 4) * SCALE } + }) + : null; + + return el("div", { + style: { + display: "flex", + flexDirection: "row", + alignItems: "center", + padding: HEADER_PADDING, + borderBottom: `1px solid ${BORDER}`, + backgroundColor: HEADER_BACKGROUND + } + }, + avatar, + el("div", { style: { display: "flex", flexDirection: "column" } }, + el("div", { style: { display: "flex", flexDirection: "row", alignItems: "center", marginBottom: 4 * SCALE } }, + el("div", { + style: { display: "flex", flexDirection: "row", alignItems: "center", marginRight: 20 * SCALE } + }, + el("div", { style: { color: TEXT_STRONG, fontSize: 34 * SCALE, fontWeight: 600 } }, handle), + flairIcon + ), + reputationPill + ), + name + ? el("div", { style: { display: "flex", color: TEXT_NAME, fontSize: 24 * SCALE, marginTop: 4 * SCALE } }, name) + : null + ), + // Pushes the badges and tags to the right, the way .ml-auto does. + el("div", { style: { display: "flex", flexGrow: 1 } }), + el("div", { style: { display: "flex", flexDirection: "column", alignItems: "flex-end" } }, + badges.length + ? el("div", { style: { display: "flex", flexDirection: "row" } }, + ...badges.slice(0, 5).map((badge) => + el("img", { + src: svgDataUri(medallionSvg(badge)), + width: BADGE_SIZE, + height: BADGE_SIZE, + style: { marginLeft: BADGE_GAP } + }) + ) + ) + : null, + tags.length + ? el("div", { style: { display: "flex", flexDirection: "row", marginTop: 12 * SCALE } }, + ...tags.map((tag) => { + const tagIcon = TAG_ICONS[tag]; + + // .tag.founder is .border-gradient, built the same way as the pill. + return el("div", { + style: { + display: "flex", + marginLeft: 8 * SCALE, + padding: 1 * SCALE, + borderRadius: 100, + backgroundImage: `linear-gradient(${GRADIENT_FROM}, ${GRADIENT_TO})` + } + }, + el("div", { + style: { + display: "flex", + alignItems: "center", + paddingLeft: 16 * SCALE, + paddingRight: 16 * SCALE, + paddingTop: 6 * SCALE, + paddingBottom: 6 * SCALE, + borderRadius: 100, + backgroundColor: TAG_BACKGROUND, + color: TEXT_STRONG, + fontSize: 14 * SCALE, + fontWeight: 600 + } + }, + tagIcon + ? el("img", { + // .tag .c-icon is 16px, filtered to textColor6. + src: iconDataUri(tagIcon, 16 * SCALE, TEXT_DIM), + width: 16 * SCALE, + height: 16 * SCALE, + style: { marginRight: 10 * SCALE } + }) + : null, + tag + ) + ); + }) + ) + : null + ) + ); +} + +function categoryRow({ id, metric, reputation }) { + return el("div", { + style: { display: "flex", flexDirection: "row", alignItems: "center", marginBottom: 16 * SCALE } + }, + el("img", { + src: svgDataUri(hexIconSvg(id)), + width: HEX_ICON_SIZE, + height: HEX_ICON_SIZE, + style: { marginRight: 16 * SCALE } + }), + // Both this column and the metric inside it need an explicit width: without + // one the metric lays out as a single unbroken flex item and collides with + // the reputation column instead of wrapping. + el("div", { style: { display: "flex", flexDirection: "column", width: METRIC_WIDTH } }, + el("div", { style: { display: "flex", color: TEXT_STRONG, fontSize: 22 * SCALE, fontWeight: 600 } }, + CATEGORY_TITLES[id]), + metric + ? el("div", { + style: { + display: "flex", + width: METRIC_WIDTH, + color: TEXT_MUTED, + fontSize: 15 * SCALE, + lineHeight: 1.4 + } + }, metric) + : null + ), + el("div", { + style: { + display: "flex", + marginLeft: 24 * SCALE, + color: TEXT_DIM, + fontSize: 15 * SCALE + } + }, + reputation === 0 ? "No rep" : `${Number(reputation).toLocaleString("en-US")} rep`) + ); +} + +function card(data) { + const byId = new Map((data.categories || []).map((category) => [category.id, category])); + // A missing category is a zero, not a missing row - the chart's axes have to + // line up with the list beside it. + const categories = CATEGORIES.map( + (id) => byId.get(id) || { id, metric: null, reputation: 0 } + ); + + return el("div", { + style: { + display: "flex", + flexDirection: "column", + width: "100%", + height: "100%", + backgroundColor: BACKGROUND, + fontFamily: "Poppins" + } + }, + header(data.header || {}), + el("div", { + style: { + display: "flex", + flexDirection: "row", + flexGrow: 1, + position: "relative", + paddingTop: 20 * SCALE, + paddingLeft: HEADER_PADDING, + paddingRight: HEADER_PADDING, + paddingBottom: 20 * SCALE + } + }, + el("img", { + src: watermark(), + width: 60 * SCALE, + height: 60 * SCALE, + style: { position: "absolute", bottom: 16 * SCALE, right: 16 * SCALE, opacity: 0.3 } + }), + el("div", { style: { display: "flex", flexDirection: "column" } }, + ...categories.map(categoryRow) + ), + el("div", { style: { display: "flex", flexGrow: 1 } }), + el("img", { + src: svgDataUri(radarChartSvg(categories.map((category) => category.reputation))), + width: CHART_RENDER, + height: CHART_RENDER, + // Centred against the category list rather than pinned to the top of + // the body, where .mt-[-50px] leaves it on the website. + style: { alignSelf: "center", marginTop: 8 * SCALE } + }) + ) + ); +} + +async function fetchData(dataUrl) { + const response = await fetch(dataUrl); + + if (!response.ok) { + throw new Error(`Fetching ${dataUrl} failed with ${response.status}`); + } + + return response.json(); +} + +async function generate({ dataUrl }) { + const data = await fetchData(dataUrl); + + const svg = await satori(card(data), { width: WIDTH, fonts: fonts() }); + const png = new Resvg(svg, { fitTo: { mode: "width", value: WIDTH } }).render().asPng(); + + return { body: png, contentType: "image/png" }; +} + +module.exports = { generate, card, radarChartSvg, hexIconSvg, padReputation, CATEGORIES }; diff --git a/profile_renderer.test.js b/profile_renderer.test.js new file mode 100644 index 0000000..4670b8d --- /dev/null +++ b/profile_renderer.test.js @@ -0,0 +1,294 @@ +const { test } = require("node:test"); +const assert = require("node:assert"); +const satori = require("satori").default; +const { fonts } = require("./satori_renderer"); +const { card, radarChartSvg, hexIconSvg, padReputation, CATEGORIES } = require("./profile_renderer"); + +const FIXTURE = require("./dev/fixtures/profile.json"); + +// Carried over from use-chart.ts: without it an empty category pulls its vertex +// into the centre and the polygon gets a spike. +test("pads every value by an eighth of the total", () => { + const padded = padReputation([0, 800]); + + assert.deepStrictEqual(padded, [100, 900]); +}); + +test("gives a zero category a non-zero radius", () => { + const [zero] = padReputation([0, 100, 100]); + + assert.ok(zero > 0); +}); + +// If this drifts, the polygon silently rotates out of step with the list. +test("puts the first axis at the top", () => { + const svg = radarChartSvg([100, 0, 0, 0, 0, 0]); + const polygon = svg.match(/ { + const svg = radarChartSvg([1, 2, 3, 4, 5, 6]); + + // Five web rings plus the data polygon. + assert.strictEqual((svg.match(/ { + const svg = radarChartSvg([0, 0, 0, 0, 0, 0]); + + assert.ok(!svg.includes("NaN")); +}); + +// The six rows always have to line up with the chart's six axes. +test("renders all six categories even when the payload omits them", () => { + const rows = card({ header: { handle: "someone", reputation: 0 }, categories: [] }); + + // The row list is the first child of the body, after the header. + const body = rows.props.children[1]; + const list = body.props.children.find( + (child) => Array.isArray(child?.props?.children) && child.props.children.length === 6 + ); + + assert.ok(list, "expected a column of six category rows"); + assert.strictEqual(CATEGORIES.length, 6); +}); + +// satori throws on an unsupported style or a bad element shape rather than +// degrading, so accepting the tree at all is the assertion. +test("lays the full fixture out without throwing", async () => { + const svg = await satori(card(FIXTURE), { width: 1600, fonts: fonts() }); + + assert.ok(svg.startsWith(" 1000); +}); + +test("handles a missing avatar", async () => { + const data = { ...FIXTURE, header: { ...FIXTURE.header, avatar_url: null } }; + const svg = await satori(card(data), { width: 1600, fonts: fonts() }); + + assert.ok(svg.startsWith(" { + const fs = require("node:fs"); + const path = require("node:path"); + const dir = path.join(__dirname, "assets", "badge-icons"); + const index = JSON.parse(fs.readFileSync(path.join(dir, "badge-icons.json"), "utf8")); + + const slugs = Object.keys(index); + assert.ok(slugs.length > 40, `expected the full badge list, got ${slugs.length}`); + + for (const [slug, { icon, rarity }] of Object.entries(index)) { + assert.ok( + fs.existsSync(path.join(dir, `${icon}.svg`)), + `${slug} points at ${icon}.svg, which isn't vendored` + ); + assert.ok( + ["common", "rare", "ultimate", "legendary"].includes(rarity), + `${slug} has an unknown rarity: ${rarity}` + ); + } +}); + +// The ways a glyph can go missing are all silent - an unmeasurable icon or a +// flooded outline both draw a plausible empty hexagon - and the icons with no +// viewBox are the ones that broke, so this walks the set rather than sampling. +test("every badge draws a glyph inside its medallion", () => { + const fs = require("node:fs"); + const path = require("node:path"); + const index = JSON.parse( + fs.readFileSync(path.join(__dirname, "assets", "badge-icons", "badge-icons.json"), "utf8") + ); + + for (const [slug, { icon, rarity }] of Object.entries(index)) { + // Keyed by icon, which is the form the payload actually sends. + const tree = card({ + header: { handle: "someone", reputation: 1, badges: [{ icon, rarity }] }, + categories: [] + }); + + const medallion = JSON.stringify(tree) + .match(/data:image\/svg\+xml;base64,([A-Za-z0-9+/=]+)/g) + .map((uri) => Buffer.from(uri.split("base64,")[1], "base64").toString("utf8")) + // The medallion is the only one of these carrying a rarity glow. + .find((svg) => svg.includes("feGaussianBlur")); + + assert.ok(medallion, `${slug} drew no medallion`); + assert.match( + medallion, + /]*transform="translate\([\d.-]+ [\d.-]+\) scale/, + `${slug} drew an empty medallion - its icon contributed no glyph` + ); + assert.ok(!medallion.includes("NaN"), `${slug} put a NaN in the medallion`); + assert.ok(!medallion.includes("undefined"), `${slug} left an undefined in the medallion`); + } +}); + +// Both payload shapes are in the fixtures and both have to reach the same +// artwork. The slug form is lossy upstream - Badges::Completed12In23Badge +// underscores to "completed12_in23" - which is why the icon is sent instead. +test("accepts a badge by icon and by slug alike", () => { + const medallionFor = (badge) => { + const tree = card({ + header: { handle: "someone", reputation: 1, badges: [badge] }, + categories: [] + }); + + return JSON.stringify(tree) + .match(/data:image\/svg\+xml;base64,([A-Za-z0-9+/=]+)/g) + .map((uri) => Buffer.from(uri.split("base64,")[1], "base64").toString("utf8")) + .find((svg) => svg.includes("feGaussianBlur")); + }; + + const byIcon = medallionFor({ icon: "moss", rarity: "legendary" }); + const bySlug = medallionFor({ slug: "moss", rarity: "legendary" }); + + assert.match(byIcon, /]*transform="translate\([\d.-]+ [\d.-]+\) scale/); + assert.strictEqual(byIcon, bySlug, "the two payload shapes drew different medallions"); +}); + +// The website can add a badge at any time, so this has to degrade, not throw. +test("draws a plain medallion for an unvendored icon", async () => { + const data = { + ...FIXTURE, + header: { ...FIXTURE.header, avatar_url: null, badges: [{ icon: "not_an_icon", rarity: "rare" }] } + }; + const svg = await satori(card(data), { width: 1600, fonts: fonts() }); + + assert.ok(svg.startsWith(" { + const data = { + ...FIXTURE, + header: { ...FIXTURE.header, avatar_url: null, badges: [{ slug: "not_a_badge", rarity: "rare" }] } + }; + const svg = await satori(card(data), { width: 1600, fonts: fonts() }); + + assert.ok(svg.startsWith(" { + const tree = card(FIXTURE); + const body = tree.props.children[1]; + const list = body.props.children.find( + (child) => Array.isArray(child?.props?.children) && child.props.children.length === 6 + ); + // Authoring is the row with the longest metric in the fixture. + const authoring = list.props.children[CATEGORIES.indexOf("authoring")]; + const [, column, reputation] = authoring.props.children; + const [, metric] = column.props.children; + + assert.ok(column.props.style.width > 0, "the title/metric column has no width"); + assert.ok(metric.props.style.width > 0, "the metric itself has no width, so it won't wrap"); + assert.strictEqual(metric.props.style.width, column.props.style.width); + assert.ok(reputation.props.style.marginLeft > 0, "no gap before the reputation column"); +}); + +// A missing icon file throws at render time rather than degrading, so the set +// dev/sync-profile-icons.sh copies is pinned here rather than found in prod. +test("every icon the profile draws is vendored", () => { + const fs = require("node:fs"); + const path = require("node:path"); + const dir = path.join(__dirname, "assets", "profile-icons"); + + const required = [ + // The hexagon behind every category glyph. + "hex", + // The shield in the reputation pill and the {~} in the founder tag. + "reputation", + "logo", + // One per HandleWithFlair flair. + "staff-flair", + "insiders", + "lifetime-insiders", + // One per category, in CATEGORY_ICONS order. + "community-solutions", + "mentoring", + "authoring", + "building", + "maintaining", + "more-horizontal" + ]; + + for (const name of required) { + assert.ok(fs.existsSync(path.join(dir, `${name}.svg`)), `${name}.svg isn't vendored`); + } +}); + +// fill="none" is what stops an outlined icon being flooded into a solid blob, +// so it has to survive the recolour rewrite. +test("keeps fill=none intact when recolouring an icon", () => { + // Every path in the authoring pencil is fill="none" with a stroke. + const svg = hexIconSvg("authoring"); + + assert.ok(svg.includes('fill="none"'), "the pencil outline lost its fill=none"); + assert.ok(svg.includes("stroke="), "expected the glyph to keep its strokes"); + assert.ok(!svg.includes("NaN")); +}); + +// The payload comes from a separate repo, so an unknown id has to fall back. +test("draws a hexagon for every category and for an unknown one", () => { + for (const id of [...CATEGORIES, "not_a_category"]) { + const svg = hexIconSvg(id); + + assert.ok(svg.startsWith(" { + for (const flair of [undefined, "founder", "staff", "insider", "lifetime_insider"]) { + const data = { ...FIXTURE, header: { ...FIXTURE.header, avatar_url: null, flair } }; + const svg = await satori(card(data), { width: 1600, fonts: fonts() }); + + assert.ok(svg.startsWith(" { + const data = { ...FIXTURE, header: { ...FIXTURE.header, avatar_url: null, flair: "wizard" } }; + const svg = await satori(card(data), { width: 1600, fonts: fonts() }); + + assert.ok(svg.startsWith(" { + const data = { + ...FIXTURE, + header: { ...FIXTURE.header, avatar_url: null, tags: ["Maintainer"] } + }; + const svg = await satori(card(data), { width: 1600, fonts: fonts() }); + + assert.ok(svg.startsWith(" { + const data = { + ...FIXTURE, + header: { ...FIXTURE.header, handle: "山田太郎", name: "山田 太郎", avatar_url: null } + }; + const svg = await satori(card(data), { width: 1600, fonts: fonts() }); + + assert.ok(svg.startsWith(" const assetFont = (file) => fs.readFileSync(path.join(__dirname, "assets", "fonts", file)); -// satori picks a font file per (family, weight, style) and silently renders -// nothing for a combination that isn't registered - no error, just missing -// glyphs. Every combination the theme can ask for has to be loaded, so both -// weights are registered in both styles: hljs-comment and friends are italic, -// hljs-strong is bold. -// -// Code can be in any language, and fontsource splits Source Code Pro into -// per-script files, so every subset has to be registered or anything outside -// latin renders as tofu - silently, since satori never errors on a missing -// glyph. -// -// How they're named matters, and the obvious spellings both fail: -// -// - All under one family name: satori uses the first face registered for a -// given (family, weight, style) and never consults the rest, so latin wins -// and every other script is tofu. -// - One family per subset: satori falls back across families, but matches -// candidates on style too. An italic span finds the italic face of the -// primary family, and that match stops it looking further - so italic text -// is tofu even though the glyph exists in a registered fallback. -// -// Naming each (subset, style) separately leaves an italic span with no -// same-family italic competitor, so the fallback resolves. Only the primary -// latin family is referenced by name; satori discovers the rest on its own. +// satori renders an unregistered (family, weight, style) as tofu without +// erroring, so every combination a theme can ask for is registered across every +// fontsource subset. The naming is load-bearing, and both obvious spellings +// fail: under one family name satori keeps the first face registered and latin +// wins, while one family per subset lets an italic span match the primary +// family's italic face and stop the fallback search. Naming each (subset, +// style) separately leaves no same-family competitor, so the fallback resolves. const MONO_SUBSETS = ["cyrillic", "greek", "vietnamese"]; const MONO_FAMILY = "Source Code Pro"; @@ -121,36 +82,13 @@ const monoFace = (subset, weight, style, name) => ({ const eachWeightAndStyle = (fn) => [400, 600].flatMap((weight) => ["normal", "italic"].map((style) => fn(weight, style))); -// Source Code Pro has no CJK or emoji glyphs in any subset, so no amount of -// registering fontsource files covers them - they need different faces -// entirely, or Japanese, Chinese, Korean and emoji all render as tofu boxes. -// Silently, again: satori never errors on a missing glyph, and the S3 -// write-through means one bad render is cached and served for that URL forever. -// -// These are built by dev/build-fonts.sh and committed under assets/fonts, -// rather than pulled from @fontsource at install time, because: -// -// - satori cannot read woff2, only woff/ttf/otf. fontsource's woff copies of -// a whole CJK face are far too big to ship in the Lambda image. -// - fontsource splits CJK into ~125 numbered subsets per weight, and the -// common ideographs are spread across most of them. That is too many faces -// to register, so the script merges them and re-cuts the ranges we need. -// -// cjk-400.woff 2.6M kana, CJK punctuation, fullwidth forms and the -// CJK Unified Ideographs block. Japanese and -// Simplified Chinese share that block, so one face -// serves both. -// hangul-400.woff 904K Hangul syllables and jamo. -// emoji-400.woff 455K Noto Emoji, which is monochrome. Colour emoji fonts -// are CBDT/COLR and resvg won't draw them, so emoji -// come out as black-and-white glyphs rather than not -// at all. -// -// Only one weight and style each. These are fallbacks: a bold or italic CJK -// span gets the regular face rather than nothing, which is the right trade for -// several megabytes an image. They follow the same per-style naming rule as the -// mono subsets above - registering them under one name would let an italic span -// match the primary family and stop the fallback search, tofuing italic CJK. +// Source Code Pro ships no CJK or emoji glyphs in any subset, so these cover +// them. They're built by dev/build-fonts.sh and committed rather than pulled +// from @fontsource, which only has them as woff2 (satori can't read it) split +// across ~125 subsets per weight (too many faces to register). Emoji are +// monochrome Noto: resvg won't draw the CBDT/COLR colour fonts. One weight and +// style each - a bold or italic CJK span gets the regular face rather than +// nothing - under the same per-style naming rule as the mono subsets above. const FALLBACK_FILES = ["cjk-400.woff", "hangul-400.woff", "emoji-400.woff"]; const fallbackFaces = () => @@ -186,8 +124,8 @@ function fonts() { return fontCache; } -// satori takes React-element-shaped objects. Building them by hand keeps this a -// plain Node project - no JSX, so no build step in the Lambda image. +// satori takes React-element-shaped objects. Built by hand to keep this a plain +// Node project - no JSX, so no build step in the Lambda image. const el = (type, props, ...children) => ({ type, props: { ...props, children: children.length > 1 ? children : children[0] } @@ -205,10 +143,7 @@ function codeLine(tokens, number, theme, indentSize) { // pre-wrap, not pre: indentation has to survive, but a long string // literal still needs to break rather than run off the card. whiteSpace: "pre-wrap", - // The track's configured width for a literal tab. The website sets - // this as `style={{ tabSize: indentSize }}` on the code element - // (FileViewer.tsx), so tab-indented tracks - Go, Nim, Zig - line up - // the way their track intends rather than at a default width. + // What FileViewer.tsx sets, so tab-indented tracks line up as intended. tabSize: indentSize } }, text); @@ -279,8 +214,6 @@ function card(data) { const lines = highlight(file.content, data.code.language).slice(0, MAX_LINES); const theme = data.highlight_theme || {}; - // Space-indented tracks never hit this; it only matters where the source - // carries literal tabs. const indentSize = data.code.indent_size || DEFAULT_INDENT_SIZE; return el("div", { @@ -307,11 +240,9 @@ function card(data) { display: "flex", flexDirection: "column", flexGrow: 1, - // The watermark is positioned against this, matching the - // `flex-grow relative` wrapper it sits in on the Chrome layout. + // The watermark is positioned against this. position: "relative", - // .c-iteration-pane is py-16 and the code block inside it py-8; the - // horizontal inset has no direct equivalent, the gutter provides it. + // .c-iteration-pane's py-16 plus the code block's own py-8. paddingTop: CODE_PADDING + CODE_VERTICAL_PADDING, paddingBottom: CODE_PADDING + CODE_VERTICAL_PADDING, fontFamily: "Source Code Pro", @@ -347,12 +278,8 @@ async function fetchData(dataUrl) { return response.json(); } -// dataUrl points at the internal ALB, so this never leaves the VPC. Fetching -// from the public site meant going out through Cloudflare and needing the NAT -// address allowlisted to get back in - the coupling that left every image -// timing out for four days when bot mitigation was turned on. -// -// Built in index.js so the rawPath -> URL mapping stays in one place. +// dataUrl points at the internal ALB, so this never leaves the VPC. It's built +// in index.js, so the rawPath -> URL mapping stays in one place. async function generate({ dataUrl }) { const data = await fetchData(dataUrl); @@ -362,6 +289,5 @@ async function generate({ dataUrl }) { return { body: png, contentType: "image/png" }; } -// fonts is exported for fonts.test.js, which renders strings through the real -// face list to check nothing tofus. +// fonts is exported for fonts.test.js, which checks nothing tofus. module.exports = { generate, fonts }; diff --git a/tokenizer.js b/tokenizer.js index 39f1c5d..fbfd896 100644 --- a/tokenizer.js +++ b/tokenizer.js @@ -1,12 +1,9 @@ const hljs = require("highlight.js"); -// highlight.js hands back an HTML string. We need coloured runs of text, and -// satori has no stylesheets to colour spans with, so unpick the markup into -// tokens and let the caller apply the theme it was given. -// -// Parsing hljs's own output rather than driving its emitter API on purpose: the -// output is a tiny, stable subset (nested spans and escaped text) whereas the -// emitter interface is internal and has moved between major versions. +// Unpicks the HTML string highlight.js hands back into coloured runs of text, +// since satori has no stylesheets to colour spans with. Parsing that output is +// deliberate: it's a tiny stable subset, whereas hljs's emitter API is internal +// and has moved between major versions. const TAG = /|<\/span>/g; @@ -23,8 +20,7 @@ function decode(text) { return text.replace(/&(?:amp|lt|gt|quot|#x27|#39);/g, (entity) => ENTITIES[entity]); } -// A span can carry several classes ("hljs-title function_"); the hljs-prefixed -// one is the scope our theme is keyed on. +// A span can carry several classes; the hljs-prefixed one keys the theme. function scopeOf(classNames) { const scope = classNames.split(/\s+/).find((name) => name.startsWith("hljs-")); return scope ? scope.slice("hljs-".length) : null; @@ -52,8 +48,7 @@ function parse(html) { return tokens; } -// Lines are what we lay out, so split tokens on newlines while keeping each -// fragment's scope. +// Splits tokens on newlines, keeping each fragment's scope. function intoLines(tokens) { const lines = [[]]; @@ -69,8 +64,7 @@ function intoLines(tokens) { return lines; } -// Falls back to plain unhighlighted lines for languages highlight.js doesn't -// know - better a readable image than none. +// Falls back to plain lines for a language highlight.js doesn't know. function highlight(code, language) { if (!language || !hljs.getLanguage(language)) { return intoLines([{ text: code, scope: null }]);