Files
iptvnator/apps/website/src/layouts/BlogPost.astro
T
4grayandClaude Opus 5 61ac15372c 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>
2026-09-12 17:19:04 +02:00

192 lines
6.4 KiB
Plaintext

---
import BaseLayout from './BaseLayout.astro';
import BlogTagChip from '../components/BlogTagChip.astro';
import type { BlogTag } from '../lib/blog-tags';
import GiscusComments from '../components/GiscusComments.astro';
import FaqAccordion from '../components/blog/FaqAccordion.astro';
interface FaqEntry {
q: string;
a: string;
}
interface Props {
title: string;
description: string;
pubDate: Date;
updatedDate?: Date;
author: string;
tags?: BlogTag[];
heroImage?: string;
/** Optional FAQ from the post frontmatter: rendered after the body and emitted as FAQPage schema. */
faq?: FaqEntry[];
}
const { title, description, pubDate, updatedDate, author, tags = [], heroImage, faq = [] } = Astro.props;
const formattedDate = pubDate.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const formattedUpdatedDate = updatedDate?.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
/** 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',
'@type': 'BlogPosting',
headline: title,
description,
datePublished: pubDate.toISOString(),
dateModified: (updatedDate ?? pubDate).toISOString(),
author: {
'@type': 'Person',
name: author,
url: 'https://github.com/4gray',
},
publisher: {
'@type': 'Organization',
name: 'IPTVnator',
url: 'https://4gray.github.io/iptvnator/',
logo: {
'@type': 'ImageObject',
url: 'https://4gray.github.io/iptvnator/favicon-256x256.png',
},
},
mainEntityOfPage: canonicalURL,
image: heroImage ? new URL(heroImage, Astro.site).toString() : undefined,
};
const faqSchema =
faq.length > 0
? {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faq.map((entry) => ({
'@type': 'Question',
name: entry.q,
acceptedAnswer: { '@type': 'Answer', text: entry.a },
})),
}
: null;
const jsonLd = faqSchema ? [blogPostSchema, faqSchema] : blogPostSchema;
---
<BaseLayout
title={`${title} | IPTVnator Blog`}
description={description}
image={heroImage}
type="article"
publishedTime={pubDate.toISOString()}
modifiedTime={(updatedDate ?? pubDate).toISOString()}
keywords={tags}
jsonLd={jsonLd}
>
<article class="pt-36 pb-24">
<div class="relative mx-auto max-w-3xl px-6">
<div class="pointer-events-none absolute -top-16 -right-14 hidden w-44 rotate-[-8deg] opacity-30 mix-blend-screen md:block lg:w-52">
<img
src="/iptvnator/illustrations/blog-corner-signal-mark.svg"
alt=""
aria-hidden="true"
class="w-full object-contain"
loading="lazy"
/>
</div>
<!-- Back link -->
<a
href="/iptvnator/blog/"
class="inline-flex items-center gap-1.5 font-mono text-xs uppercase tracking-[0.1em] text-surface-500 transition-colors hover:text-accent-400"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Back to blog
</a>
<!-- Header -->
<header class="mt-10">
<div class="flex flex-wrap items-center gap-2">
<time datetime={pubDate.toISOString()} class="font-mono text-xs text-surface-500">
{formattedDate}
</time>
{
tags.length > 0 && (
<>
<span class="text-surface-700">&middot;</span>
{tags.map((tag) => (
<BlogTagChip tag={tag} class="px-2.5 font-medium" />
))}
</>
)
}
</div>
<h1 class="mt-5 font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl">
{title}
</h1>
<p class="mt-4 text-lg text-surface-400">{description}</p>
<div class="mt-6 flex items-center gap-3">
<img
src={AUTHOR_AVATAR_URL}
alt={`${author} avatar`}
class="h-8 w-8 rounded-full border border-surface-700 object-cover"
loading="lazy"
/>
<div>
<div class="text-sm font-medium text-surface-100">{author}</div>
{
updatedDate && (
<div class="font-mono text-[11px] text-surface-500">
Updated {formattedUpdatedDate}
</div>
)
}
</div>
</div>
<hr class="section-divider mt-10" />
</header>
<!-- Hero image -->
{heroImage && (
<div class="relative mt-10">
<div class="absolute -inset-6 rounded-2xl bg-accent-500/[0.03] blur-2xl" />
<div class="relative overflow-hidden rounded-xl border border-surface-700/60 bg-surface-900 shadow-lg shadow-black/20">
<img src={heroImage} alt="" class="w-full" loading="eager" />
</div>
</div>
)}
<!-- Content -->
<div
class="prose prose-invert mt-12 max-w-none prose-headings:font-display prose-headings:font-semibold prose-headings:tracking-tight prose-headings:text-surface-100 prose-p:text-surface-300 prose-a:text-accent-400 prose-a:no-underline prose-a:decoration-accent-500/30 hover:prose-a:underline prose-strong:text-surface-100 prose-code:rounded prose-code:bg-surface-800/50 prose-code:px-1.5 prose-code:py-0.5 prose-code:font-mono prose-code:text-accent-300 prose-code:before:content-[''] prose-code:after:content-[''] prose-pre:border prose-pre:border-surface-700/60 prose-pre:bg-surface-900/50 prose-blockquote:border-accent-500/30 prose-blockquote:text-surface-400 prose-hr:border-surface-700/60"
>
<slot />
</div>
{faq.length > 0 && (
<section class="mt-16" aria-labelledby="post-faq-heading">
<hr class="section-divider" />
<span class="mt-12 block font-mono text-xs uppercase tracking-[0.2em] text-accent-500">FAQ</span>
<h2 id="post-faq-heading" class="mt-3 font-display text-2xl font-bold tracking-tight text-surface-50 sm:text-3xl">
Frequently asked questions
</h2>
<FaqAccordion items={faq} />
</section>
)}
<GiscusComments />
</div>
</article>
</BaseLayout>