From 61ac15372cde4dce75159c01c762db1102d03021 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 12 Sep 2026 17:19:04 +0200 Subject: [PATCH] perf(website): serve fonts and the avatar locally, load comments on demand Every page pulled its three typefaces from fonts.googleapis.com and fonts.gstatic.com, each blog post fetched the author avatar from githubusercontent.com, and the giscus client script ran on page load. That is four outside origins contacted before a reader does anything, each costing a DNS lookup and a TLS handshake on the critical path. Fonts now come from the @fontsource packages the app already uses and are emitted as .woff2 beside the site; the avatar is a 3 KB file in public/; and the giscus embed is created by a "Show comments" button that carries the configuration as data attributes, so the script is only built when a reader asks for it. A delivered page now makes no third-party request at all, verified across the whole build. The variable Bricolage package names itself "Bricolage Grotesque Variable", so that exact name leads the display stack in the Tailwind config. The giscus test now checks the button configuration and asserts the client script is absent from the delivered HTML. Co-Authored-By: Claude Opus 5 --- apps/website/README.md | 26 +++++ apps/website/public/author-4gray.jpg | Bin 0 -> 3081 bytes apps/website/src/components/BaseHead.astro | 8 -- .../src/components/GiscusComments.astro | 98 ++++++++++++++---- apps/website/src/layouts/BaseLayout.astro | 11 ++ apps/website/src/layouts/BlogPost.astro | 3 +- apps/website/tailwind.config.mjs | 8 +- package.json | 2 + pnpm-lock.yaml | 17 ++- .../testing/website-giscus-comments.test.mjs | 29 ++++-- 10 files changed, 162 insertions(+), 40 deletions(-) create mode 100644 apps/website/public/author-4gray.jpg diff --git a/apps/website/README.md b/apps/website/README.md index 881715a72..b85179cda 100644 --- a/apps/website/README.md +++ b/apps/website/README.md @@ -7,6 +7,14 @@ The website is an Astro static site deployed to GitHub Pages at `https://4gray.g Blog posts render Giscus comments from `apps/website/src/components/GiscusComments.astro`. Giscus stores comments in GitHub Discussions for `4gray/iptvnator` and maps each page to a discussion by `pathname`, including the GitHub Pages base path such as `/iptvnator/blog/why-external-players-help/`. +The embed is **click-to-load**: the configuration sits as `data-*` attributes on a +"Show comments" button and the `giscus.app` script is created only when a reader +presses it, so opening a post requests nothing from giscus.app or github.com. +Keep it that way — pasting the upstream ` +
+ +
+ +

+ Comments are hosted in GitHub Discussions. Loading them contacts giscus.app and github.com; + nothing is requested from them until you choose to. +

+
+ + diff --git a/apps/website/src/layouts/BaseLayout.astro b/apps/website/src/layouts/BaseLayout.astro index 4a6ba3dc4..e03101067 100644 --- a/apps/website/src/layouts/BaseLayout.astro +++ b/apps/website/src/layouts/BaseLayout.astro @@ -2,6 +2,17 @@ import BaseHead from '../components/BaseHead.astro'; import Header from '../components/Header.astro'; import Footer from '../components/Footer.astro'; +// Fonts ship with the site instead of being fetched from a font CDN, so a +// page load makes no third-party request and the faces are already cached +// with the rest of the assets. +import '@fontsource-variable/bricolage-grotesque/wght.css'; +import '@fontsource/dm-sans/400.css'; +import '@fontsource/dm-sans/400-italic.css'; +import '@fontsource/dm-sans/500.css'; +import '@fontsource/dm-sans/600.css'; +import '@fontsource/dm-sans/700.css'; +import '@fontsource/ibm-plex-mono/400.css'; +import '@fontsource/ibm-plex-mono/500.css'; import '../styles/global.css'; interface Props { diff --git a/apps/website/src/layouts/BlogPost.astro b/apps/website/src/layouts/BlogPost.astro index a2c704752..41113abfd 100644 --- a/apps/website/src/layouts/BlogPost.astro +++ b/apps/website/src/layouts/BlogPost.astro @@ -36,7 +36,8 @@ const formattedUpdatedDate = updatedDate?.toLocaleDateString('en-US', { day: 'numeric', }); -const AUTHOR_AVATAR_URL = 'https://avatars.githubusercontent.com/u/1503032'; +/** Served from the site: an avatar fetched from GitHub would report every reader to them. */ +const AUTHOR_AVATAR_URL = '/iptvnator/author-4gray.jpg'; const canonicalURL = new URL(Astro.url.pathname, Astro.site).toString(); const blogPostSchema = { '@context': 'https://schema.org', diff --git a/apps/website/tailwind.config.mjs b/apps/website/tailwind.config.mjs index 26dc719c6..adb9df23e 100644 --- a/apps/website/tailwind.config.mjs +++ b/apps/website/tailwind.config.mjs @@ -41,7 +41,13 @@ export default { }, }, fontFamily: { - display: ['"Bricolage Grotesque"', '"DM Sans"', 'system-ui', 'sans-serif'], + display: [ + '"Bricolage Grotesque Variable"', + '"Bricolage Grotesque"', + '"DM Sans"', + 'system-ui', + 'sans-serif', + ], body: ['"DM Sans"', 'system-ui', 'sans-serif'], mono: ['"IBM Plex Mono"', 'monospace'], }, diff --git a/package.json b/package.json index 12e252250..54995798a 100644 --- a/package.json +++ b/package.json @@ -161,8 +161,10 @@ "@eslint/eslintrc": "3.3.7", "@eslint/js": "^9.38.0", "@faker-js/faker": "10.6.0", + "@fontsource-variable/bricolage-grotesque": "5.3.0", "@fontsource/crimson-pro": "5.3.0", "@fontsource/dm-sans": "5.3.0", + "@fontsource/ibm-plex-mono": "5.3.0", "@fontsource/jetbrains-mono": "5.3.0", "@fontsource/roboto": "5.3.0", "@ngrx/eslint-plugin": "^21.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5f465ac5..1051e1de2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -241,12 +241,18 @@ importers: '@faker-js/faker': specifier: 10.6.0 version: 10.6.0 + '@fontsource-variable/bricolage-grotesque': + specifier: 5.3.0 + version: 5.3.0 '@fontsource/crimson-pro': specifier: 5.3.0 version: 5.3.0 '@fontsource/dm-sans': specifier: 5.3.0 version: 5.3.0 + '@fontsource/ibm-plex-mono': + specifier: 5.3.0 + version: 5.3.0 '@fontsource/jetbrains-mono': specifier: 5.3.0 version: 5.3.0 @@ -2272,12 +2278,18 @@ packages: resolution: {integrity: sha512-3RQHgEtvL1Frl/d1cSreo7qhJ3Gk1OdNUai/CtZ8G+wYeRQnJih3s9xJ9/kgYekPQRdwgh0HXRPqMlzWGwivIQ==} engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} + '@fontsource-variable/bricolage-grotesque@5.3.0': + resolution: {integrity: sha512-TLi9Q4hJjS2UvoTMRSS2nHu6c4R56lAw60NR9QYtVRCHn0XtsFpiEhNffZ8Glsoxu6wEEwLKBP8lb94J52PNBA==} + '@fontsource/crimson-pro@5.3.0': resolution: {integrity: sha512-PXQH0NGma2wwskZAySBH+s/ezAYBwyDH0I4oRURBu9u81nNUCm5DcNCJaUS9H0UR1y8qlslLfVRU6mnDBpffrA==} '@fontsource/dm-sans@5.3.0': resolution: {integrity: sha512-lYJtMXXO28q1z+yz+z8XKd0s4hXaa9QdkETzkyD760sidCv5heI86weYA0sx0Nc4pAMAQTUuyf4gO44cYKKS9g==} + '@fontsource/ibm-plex-mono@5.3.0': + resolution: {integrity: sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==} + '@fontsource/jetbrains-mono@5.3.0': resolution: {integrity: sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==} @@ -10255,7 +10267,6 @@ packages: shaka-player@5.2.4: resolution: {integrity: sha512-vf81av2EIcb03jRpeeZBhrPT3PMyggXnZA9k4sxGBxpr/H6v0iEL6b51T2Kwz5YNrQG/yDYoadgBW+oW40LeoA==} - engines: {node: '>=18'} shallow-clone@3.0.1: resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} @@ -13617,10 +13628,14 @@ snapshots: '@faker-js/faker@10.6.0': {} + '@fontsource-variable/bricolage-grotesque@5.3.0': {} + '@fontsource/crimson-pro@5.3.0': {} '@fontsource/dm-sans@5.3.0': {} + '@fontsource/ibm-plex-mono@5.3.0': {} + '@fontsource/jetbrains-mono@5.3.0': {} '@fontsource/roboto@5.3.0': {} diff --git a/tools/testing/website-giscus-comments.test.mjs b/tools/testing/website-giscus-comments.test.mjs index 585507acc..34b7c78d8 100644 --- a/tools/testing/website-giscus-comments.test.mjs +++ b/tools/testing/website-giscus-comments.test.mjs @@ -5,7 +5,6 @@ import assert from 'node:assert/strict'; const postHtmlPath = new URL('../../dist/apps/website/blog/why-external-players-help/index.html', import.meta.url); const expectedGiscusAttributes = { - src: 'https://giscus.app/client.js', 'data-repo': '4gray/iptvnator', 'data-repo-id': 'MDEwOlJlcG9zaXRvcnkyMTMxOTQ3Mzg=', 'data-category': 'Blog comments', @@ -19,18 +18,28 @@ const expectedGiscusAttributes = { 'data-lang': 'en', }; -test('published blog posts include the Giscus comments embed configuration', async () => { +test('published blog posts carry the Giscus configuration on a click-to-load button', async () => { const html = await readFile(postHtmlPath, 'utf8'); - const scriptMatch = html.match(/]*giscus\.app\/client\.js[^>]*>/); + const buttonMatch = html.match(/]*data-giscus-loader[^>]*>/); - assert.ok(scriptMatch, 'Expected the blog post HTML to include the Giscus client script.'); + assert.ok(buttonMatch, 'Expected the blog post HTML to include the Giscus loader button.'); - const scriptTag = scriptMatch[0]; + const buttonTag = buttonMatch[0]; for (const [name, value] of Object.entries(expectedGiscusAttributes)) { - assert.match(scriptTag, new RegExp(`${name}="${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`)); + assert.match(buttonTag, new RegExp(`${name}="${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`)); } - - assert.match(scriptTag, /\bloading="lazy"/); - assert.match(scriptTag, /\bcrossorigin="anonymous"/); - assert.match(scriptTag, /\basync/); +}); + +test('a blog post loads nothing from a third party before the reader asks for comments', async () => { + const html = await readFile(postHtmlPath, 'utf8'); + + assert.ok( + !/]*\bsrc="https:\/\/giscus\.app/.test(html), + 'The giscus client must not be a script tag in the delivered HTML — it is created on click.' + ); + assert.match( + html, + /data-script-src="https:\/\/giscus\.app\/client\.js"/, + 'The loader button must carry the giscus client URL for its click handler.' + ); });