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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-09-12 17:19:04 +02:00
1 parent 7d1265d566
commit 61ac15372c
10 files changed
+162 -40

No files matched your search

+26
View File
@@ -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`. 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/`. 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 `<script src="https://giscus.app/client.js">`
snippet back into the component would contact both for every reader, and
`tools/testing/website-giscus-comments.test.mjs` fails when that script tag
appears in the delivered HTML.
The embed is wired to the dedicated `Blog comments` discussion category: The embed is wired to the dedicated `Blog comments` discussion category:
- Repository id: `MDEwOlJlcG9zaXRvcnkyMTMxOTQ3Mzg=` - Repository id: `MDEwOlJlcG9zaXRvcnkyMTMxOTQ3Mzg=`
@@ -27,6 +35,24 @@ Then update `data-category-id` in `GiscusComments.astro`.
Moderation happens in GitHub Discussions. Maintainers can hide, delete, lock, or move discussions and comments from the repository Discussions UI. Moderation happens in GitHub Discussions. Maintainers can hide, delete, lock, or move discussions and comments from the repository Discussions UI.
## Fonts And Third-Party Requests
A delivered page must make **no third-party request**. The build is checked
against this: fonts come from the `@fontsource` packages imported in
`src/layouts/BaseLayout.astro` and are emitted as `.woff2` beside the site (never
from a font CDN), the author avatar is `public/author-4gray.jpg` rather than a
`githubusercontent.com` URL, and comments are click-to-load as described above.
The display family is the **variable** package, which declares itself as
`Bricolage Grotesque Variable` — that exact name leads the `display` stack in
`tailwind.config.mjs`, with the static `Bricolage Grotesque` kept behind it as a
fallback. Adding a weight or style means adding its `@fontsource` import;
nothing is fetched at runtime.
Before adding any embed (analytics, a video, a widget, a webfont), check what it
loads. Keeping the page free of outside requests is what keeps it fast and keeps
the site from needing a consent banner.
## Download Pages ## Download Pages
`/download/` plus `/download/windows/`, `/download/macos/`, `/download/linux/` `/download/` plus `/download/windows/`, `/download/macos/`, `/download/linux/`
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -48,14 +48,6 @@ const schemaPayload = jsonLd
<meta name="format-detection" content="telephone=no" /> <meta name="format-detection" content="telephone=no" />
{keywords.length > 0 && <meta name="keywords" content={keywords.join(', ')} />} {keywords.length > 0 && <meta name="keywords" content={keywords.join(', ')} />}
<!-- Fonts: Bricolage Grotesque (display), DM Sans (body), IBM Plex Mono (code) -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,600;12..96,700;12..96,800&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=IBM+Plex+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<!-- Canonical URL --> <!-- Canonical URL -->
<link rel="canonical" href={canonicalURL.toString()} /> <link rel="canonical" href={canonicalURL.toString()} />
@@ -1,3 +1,28 @@
---
/**
* Comments live in GitHub Discussions and are embedded through giscus, which
* loads from `giscus.app` and `github.com`. Embedding it on page load would
* contact both for every reader of every post, so the embed is created only
* after an explicit click: until then the page requests nothing third-party.
*
* The configuration is carried as `data-*` attributes on the button and copied
* onto the real script, so it stays in one place and is visible in the HTML.
*/
const giscusConfig = {
'data-repo': '4gray/iptvnator',
'data-repo-id': 'MDEwOlJlcG9zaXRvcnkyMTMxOTQ3Mzg=',
'data-category': 'Blog comments',
'data-category-id': 'DIC_kwDODLUX8s4C9eBJ',
'data-mapping': 'pathname',
'data-strict': '1',
'data-reactions-enabled': '1',
'data-emit-metadata': '0',
'data-input-position': 'bottom',
'data-theme': 'transparent_dark',
'data-lang': 'en',
};
---
<section class="mt-16 border-t border-surface-700/60 pt-10" aria-labelledby="blog-comments-heading"> <section class="mt-16 border-t border-surface-700/60 pt-10" aria-labelledby="blog-comments-heading">
<div class="mb-6"> <div class="mb-6">
<h2 id="blog-comments-heading" class="font-display text-2xl font-semibold tracking-tight text-surface-100"> <h2 id="blog-comments-heading" class="font-display text-2xl font-semibold tracking-tight text-surface-100">
@@ -5,23 +30,58 @@
</h2> </h2>
</div> </div>
<div class="giscus min-h-40"></div> <div class="giscus min-h-40" data-giscus-target></div>
<script
is:inline <div data-giscus-prompt class="rounded-xl border border-surface-800 bg-surface-900/40 p-5">
src="https://giscus.app/client.js" <button
data-repo="4gray/iptvnator" type="button"
data-repo-id="MDEwOlJlcG9zaXRvcnkyMTMxOTQ3Mzg=" data-giscus-loader
data-category="Blog comments" data-script-src="https://giscus.app/client.js"
data-category-id="DIC_kwDODLUX8s4C9eBJ" {...giscusConfig}
data-mapping="pathname" class="inline-flex items-center gap-2 rounded-lg border border-surface-700 bg-surface-900 px-4 py-2 font-mono text-xs uppercase tracking-[0.1em] text-accent-400 transition-colors hover:border-accent-500/60 hover:text-accent-300"
data-strict="1" >
data-reactions-enabled="1" Show comments
data-emit-metadata="0" </button>
data-input-position="bottom" <p class="mt-3 text-sm leading-relaxed text-surface-500">
data-theme="transparent_dark" Comments are hosted in GitHub Discussions. Loading them contacts giscus.app and github.com;
data-lang="en" nothing is requested from them until you choose to.
crossorigin="anonymous" </p>
loading="lazy" </div>
async
></script> <script is:inline>
(() => {
const button = document.querySelector('[data-giscus-loader]');
const prompt = document.querySelector('[data-giscus-prompt]');
const target = document.querySelector('[data-giscus-target]');
if (!button || !prompt || !target) return;
button.addEventListener(
'click',
() => {
const script = document.createElement('script');
script.src = button.dataset.scriptSrc;
script.async = true;
script.crossOrigin = 'anonymous';
script.setAttribute('loading', 'lazy');
// Every giscus option except the two that drive this loader.
for (const attribute of button.attributes) {
if (
attribute.name.startsWith('data-') &&
attribute.name !== 'data-script-src' &&
attribute.name !== 'data-giscus-loader'
) {
script.setAttribute(attribute.name, attribute.value);
}
}
prompt.remove();
target.appendChild(script);
},
{ once: true }
);
})();
</script>
</section> </section>
+11
View File
@@ -2,6 +2,17 @@
import BaseHead from '../components/BaseHead.astro'; import BaseHead from '../components/BaseHead.astro';
import Header from '../components/Header.astro'; import Header from '../components/Header.astro';
import Footer from '../components/Footer.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'; import '../styles/global.css';
interface Props { interface Props {
+2 -1
View File
@@ -36,7 +36,8 @@ const formattedUpdatedDate = updatedDate?.toLocaleDateString('en-US', {
day: 'numeric', 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 canonicalURL = new URL(Astro.url.pathname, Astro.site).toString();
const blogPostSchema = { const blogPostSchema = {
'@context': 'https://schema.org', '@context': 'https://schema.org',
+7 -1
View File
@@ -41,7 +41,13 @@ export default {
}, },
}, },
fontFamily: { 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'], body: ['"DM Sans"', 'system-ui', 'sans-serif'],
mono: ['"IBM Plex Mono"', 'monospace'], mono: ['"IBM Plex Mono"', 'monospace'],
}, },
+2
View File
@@ -161,8 +161,10 @@
"@eslint/eslintrc": "3.3.7", "@eslint/eslintrc": "3.3.7",
"@eslint/js": "^9.38.0", "@eslint/js": "^9.38.0",
"@faker-js/faker": "10.6.0", "@faker-js/faker": "10.6.0",
"@fontsource-variable/bricolage-grotesque": "5.3.0",
"@fontsource/crimson-pro": "5.3.0", "@fontsource/crimson-pro": "5.3.0",
"@fontsource/dm-sans": "5.3.0", "@fontsource/dm-sans": "5.3.0",
"@fontsource/ibm-plex-mono": "5.3.0",
"@fontsource/jetbrains-mono": "5.3.0", "@fontsource/jetbrains-mono": "5.3.0",
"@fontsource/roboto": "5.3.0", "@fontsource/roboto": "5.3.0",
"@ngrx/eslint-plugin": "^21.1.1", "@ngrx/eslint-plugin": "^21.1.1",
+16 -1
View File
@@ -241,12 +241,18 @@ importers:
'@faker-js/faker': '@faker-js/faker':
specifier: 10.6.0 specifier: 10.6.0
version: 10.6.0 version: 10.6.0
'@fontsource-variable/bricolage-grotesque':
specifier: 5.3.0
version: 5.3.0
'@fontsource/crimson-pro': '@fontsource/crimson-pro':
specifier: 5.3.0 specifier: 5.3.0
version: 5.3.0 version: 5.3.0
'@fontsource/dm-sans': '@fontsource/dm-sans':
specifier: 5.3.0 specifier: 5.3.0
version: 5.3.0 version: 5.3.0
'@fontsource/ibm-plex-mono':
specifier: 5.3.0
version: 5.3.0
'@fontsource/jetbrains-mono': '@fontsource/jetbrains-mono':
specifier: 5.3.0 specifier: 5.3.0
version: 5.3.0 version: 5.3.0
@@ -2272,12 +2278,18 @@ packages:
resolution: {integrity: sha512-3RQHgEtvL1Frl/d1cSreo7qhJ3Gk1OdNUai/CtZ8G+wYeRQnJih3s9xJ9/kgYekPQRdwgh0HXRPqMlzWGwivIQ==} resolution: {integrity: sha512-3RQHgEtvL1Frl/d1cSreo7qhJ3Gk1OdNUai/CtZ8G+wYeRQnJih3s9xJ9/kgYekPQRdwgh0HXRPqMlzWGwivIQ==}
engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} 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': '@fontsource/crimson-pro@5.3.0':
resolution: {integrity: sha512-PXQH0NGma2wwskZAySBH+s/ezAYBwyDH0I4oRURBu9u81nNUCm5DcNCJaUS9H0UR1y8qlslLfVRU6mnDBpffrA==} resolution: {integrity: sha512-PXQH0NGma2wwskZAySBH+s/ezAYBwyDH0I4oRURBu9u81nNUCm5DcNCJaUS9H0UR1y8qlslLfVRU6mnDBpffrA==}
'@fontsource/dm-sans@5.3.0': '@fontsource/dm-sans@5.3.0':
resolution: {integrity: sha512-lYJtMXXO28q1z+yz+z8XKd0s4hXaa9QdkETzkyD760sidCv5heI86weYA0sx0Nc4pAMAQTUuyf4gO44cYKKS9g==} 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': '@fontsource/jetbrains-mono@5.3.0':
resolution: {integrity: sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==} resolution: {integrity: sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==}
@@ -10255,7 +10267,6 @@ packages:
shaka-player@5.2.4: shaka-player@5.2.4:
resolution: {integrity: sha512-vf81av2EIcb03jRpeeZBhrPT3PMyggXnZA9k4sxGBxpr/H6v0iEL6b51T2Kwz5YNrQG/yDYoadgBW+oW40LeoA==} resolution: {integrity: sha512-vf81av2EIcb03jRpeeZBhrPT3PMyggXnZA9k4sxGBxpr/H6v0iEL6b51T2Kwz5YNrQG/yDYoadgBW+oW40LeoA==}
engines: {node: '>=18'}
shallow-clone@3.0.1: shallow-clone@3.0.1:
resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==}
@@ -13617,10 +13628,14 @@ snapshots:
'@faker-js/faker@10.6.0': {} '@faker-js/faker@10.6.0': {}
'@fontsource-variable/bricolage-grotesque@5.3.0': {}
'@fontsource/crimson-pro@5.3.0': {} '@fontsource/crimson-pro@5.3.0': {}
'@fontsource/dm-sans@5.3.0': {} '@fontsource/dm-sans@5.3.0': {}
'@fontsource/ibm-plex-mono@5.3.0': {}
'@fontsource/jetbrains-mono@5.3.0': {} '@fontsource/jetbrains-mono@5.3.0': {}
'@fontsource/roboto@5.3.0': {} '@fontsource/roboto@5.3.0': {}
+19 -10
View File
@@ -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 postHtmlPath = new URL('../../dist/apps/website/blog/why-external-players-help/index.html', import.meta.url);
const expectedGiscusAttributes = { const expectedGiscusAttributes = {
src: 'https://giscus.app/client.js',
'data-repo': '4gray/iptvnator', 'data-repo': '4gray/iptvnator',
'data-repo-id': 'MDEwOlJlcG9zaXRvcnkyMTMxOTQ3Mzg=', 'data-repo-id': 'MDEwOlJlcG9zaXRvcnkyMTMxOTQ3Mzg=',
'data-category': 'Blog comments', 'data-category': 'Blog comments',
@@ -19,18 +18,28 @@ const expectedGiscusAttributes = {
'data-lang': 'en', '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 html = await readFile(postHtmlPath, 'utf8');
const scriptMatch = html.match(/<script\b[^>]*giscus\.app\/client\.js[^>]*>/); const buttonMatch = html.match(/<button\b[^>]*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)) { 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"/); test('a blog post loads nothing from a third party before the reader asks for comments', async () => {
assert.match(scriptTag, /\basync/); const html = await readFile(postHtmlPath, 'utf8');
assert.ok(
!/<script\b[^>]*\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.'
);
}); });