mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:27:41 -08:00
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>
192 lines
6.4 KiB
Plaintext
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">·</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>
|