feat: add blog with archives, pagination, search, sharing and RSS

- Content collection (one folder per post) with zod schema
- /blog, category and tag archives with /sayfa/<n>/ pagination
- Post page: breadcrumbs, date badges, share bar, TOC, DIY notice,
  code copy, license line, prev/next, BlogPosting + BreadcrumbList JSON-LD
- Sidebar: Pagefind search, categories, featured fallback, tag cloud
- Sätteri HAST plugins for figure/figcaption and external links
- Dual-theme Shiki, themed prose, RSS feed, latest posts on home
- Three sample posts marked as examples

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 01:06:07 +03:00
parent f2d943c31b
commit 809f89af08
27 changed files with 1599 additions and 27 deletions
+71
View File
@@ -0,0 +1,71 @@
---
// Shared layout for /blog/, category and tag archives (all paginated).
import BaseLayout from '../../layouts/BaseLayout.astro';
import { blog, fmt } from '../../lib/data';
import type { Post } from '../../lib/posts';
import Container from '../Container.astro';
import GridLines from '../GridLines.astro';
import SectionHeading from '../SectionHeading.astro';
import Pager from './Pager.astro';
import PostCard from './PostCard.astro';
import Sidebar from './Sidebar.astro';
export interface Props {
eyebrow: string;
title: string;
description: string;
/** Posts on this page. */
posts: Post[];
/** All published posts, for the sidebar. */
allPosts: Post[];
current: number;
total: number;
base: string;
activeCategory?: string;
activeTag?: string;
jsonLd?: Record<string, unknown>[];
}
const {
eyebrow,
title,
description,
posts,
allPosts,
current,
total,
base,
activeCategory,
activeTag,
jsonLd,
} = Astro.props;
const seoTitle = current > 1 ? fmt(blog.list.pageTitle, { title, n: current }) : title;
---
<BaseLayout title={seoTitle} description={description} jsonLd={jsonLd}>
<section class="surface-alt relative overflow-hidden">
<GridLines />
<Container class="relative py-14 md:py-20">
<SectionHeading level={1} eyebrow={eyebrow} title={title} />
<p class="mt-4 max-w-2xl text-lg">{description}</p>
</Container>
</section>
<Container class="grid gap-12 py-14 lg:grid-cols-[minmax(0,1fr)_20rem]">
<div>
{posts.length ? (
<ul class="flex flex-col gap-8">
{posts.map((post, i) => (
<li>
<PostCard post={post} priority={i === 0} />
</li>
))}
</ul>
) : (
<p class="border-fg/30 border-2 border-dashed p-8 text-center text-lg">{blog.list.empty}</p>
)}
<Pager current={current} total={total} base={base} />
</div>
<Sidebar posts={allPosts} activeCategory={activeCategory} activeTag={activeTag} />
</Container>
</BaseLayout>
+35
View File
@@ -0,0 +1,35 @@
---
import { Icon } from 'astro-icon/components';
import { blog } from '../../lib/data';
export interface Props {
/** Last item is the current page (no href needed). */
items: { label: string; href?: string }[];
}
const { items } = Astro.props;
---
<nav aria-label={blog.post.breadcrumbLabel} class="text-sm">
<ol class="flex flex-wrap items-center gap-1">
{items.map((item, i) => (
<li class="flex items-center gap-1">
{i > 0 && (
<Icon name="tabler:chevron-right" class="text-fg-muted size-4" aria-hidden="true" />
)}
{i === items.length - 1 ? (
<span aria-current="page" class="text-fg-muted line-clamp-1">
{item.label}
</span>
) : (
<a
href={item.href}
class="decoration-accent font-semibold underline-offset-4 hover:underline"
>
{item.label}
</a>
)}
</li>
))}
</ol>
</nav>
+41
View File
@@ -0,0 +1,41 @@
---
import { blog, fmt } from '../../lib/data';
import { dateBadges, formatDate, isoDay, type DateBadge } from '../../lib/dates';
import Badge from '../Badge.astro';
export interface Props {
pubDate: Date;
updatedDate?: Date;
/** Reading time in minutes, shown after the dates. */
minutes?: number;
class?: string;
}
const { pubDate, updatedDate, minutes, class: className } = Astro.props;
const { primary, secondary } = dateBadges(pubDate, updatedDate);
const meta = (b: DateBadge) =>
b.kind === 'updated'
? { label: blog.dates.updated, icon: 'tabler:refresh' }
: { label: blog.dates.created, icon: 'tabler:calendar-plus' };
const p = meta(primary);
const s = secondary && meta(secondary);
---
<div class:list={['flex flex-wrap items-center gap-2', className]}>
<Badge variant="solid" icon={p.icon}>
{p.label}
<time datetime={isoDay(primary.date)}>{formatDate(primary.date)}</time>
</Badge>
{secondary && s && (
<Badge variant="outline" icon={s.icon}>
{s.label}
<time datetime={isoDay(secondary.date)}>{formatDate(secondary.date)}</time>
</Badge>
)}
{minutes && (
<Badge variant="outline" icon="tabler:clock">
{fmt(blog.card.readingTime, { n: minutes })}
</Badge>
)}
</div>
+24
View File
@@ -0,0 +1,24 @@
---
import { Icon } from 'astro-icon/components';
import { blog } from '../../lib/data';
---
<aside
role="note"
aria-labelledby="diy-title"
class="border-accent bg-surface flex gap-4 border-l-4 p-5"
>
<Icon name="tabler:alert-triangle" class="text-accent-text size-7 shrink-0" aria-hidden="true" />
<div class="flex flex-col gap-2">
<p id="diy-title" class="font-display font-bold tracking-wide uppercase">
{blog.diy.title}
</p>
<p class="text-sm">{blog.diy.body}</p>
<a
href={blog.diy.link.href}
class="decoration-accent text-sm font-semibold underline underline-offset-4"
>
{blog.diy.link.label}
</a>
</div>
</aside>
+101
View File
@@ -0,0 +1,101 @@
---
import { Icon } from 'astro-icon/components';
import { blog, fmt } from '../../lib/data';
import { pageUrl } from '../../lib/posts';
export interface Props {
current: number;
total: number;
/** URL of page 1, e.g. `/blog/`. */
base: string;
}
const { current, total, base } = Astro.props;
// 1 … (current-1) current (current+1) … total
const numbers: (number | 'gap')[] = [];
for (let n = 1; n <= total; n++) {
if (n === 1 || n === total || Math.abs(n - current) <= 1) numbers.push(n);
else if (numbers.at(-1) !== 'gap') numbers.push('gap');
}
const controls = [
{ label: blog.pager.first, icon: 'tabler:chevrons-left', page: 1, enabled: current > 1 },
{ label: blog.pager.prev, icon: 'tabler:chevron-left', page: current - 1, enabled: current > 1 },
];
const after = [
{
label: blog.pager.next,
icon: 'tabler:chevron-right',
page: current + 1,
enabled: current < total,
},
{ label: blog.pager.last, icon: 'tabler:chevrons-right', page: total, enabled: current < total },
];
const item =
'font-display inline-flex h-10 min-w-10 items-center justify-center gap-1 border px-3 text-sm font-bold';
---
{total > 1 && (
<nav aria-label={blog.pager.label} class="mt-12 flex justify-center">
<ul class="flex flex-wrap items-center justify-center gap-2">
{controls.map((c) => (
<li>
{c.enabled ? (
<a href={pageUrl(base, c.page)} class:list={[item, 'border-line hover:border-fg']}>
{c.icon.includes('chevrons') ? null : (
<Icon name={c.icon} class="size-4" aria-hidden="true" />
)}
{c.label}
</a>
) : (
<span
aria-disabled="true"
class:list={[item, 'border-line text-fg-muted cursor-not-allowed border-dashed']}
>
{c.label}
</span>
)}
</li>
))}
{numbers.map((n) => (
<li>
{n === 'gap' ? (
<span class="px-1" aria-hidden="true">
…
</span>
) : n === current ? (
<span aria-current="page" class:list={[item, 'bg-fg text-bg border-fg']}>
<span class="sr-only">{fmt(blog.pager.page, { n })}</span>
<span aria-hidden="true">{n}</span>
</span>
) : (
<a href={pageUrl(base, n)} class:list={[item, 'border-line hover:border-fg']}>
<span class="sr-only">{fmt(blog.pager.page, { n })}</span>
<span aria-hidden="true">{n}</span>
</a>
)}
</li>
))}
{after.map((c) => (
<li>
{c.enabled ? (
<a href={pageUrl(base, c.page)} class:list={[item, 'border-line hover:border-fg']}>
{c.label}
{c.icon.includes('chevrons') ? null : (
<Icon name={c.icon} class="size-4" aria-hidden="true" />
)}
</a>
) : (
<span
aria-disabled="true"
class:list={[item, 'border-line text-fg-muted cursor-not-allowed border-dashed']}
>
{c.label}
</span>
)}
</li>
))}
</ul>
</nav>
)}
+65
View File
@@ -0,0 +1,65 @@
---
import { Image } from 'astro:assets';
import { Icon } from 'astro-icon/components';
import { categoryOf, categoryUrl, postUrl, readingMinutes, type Post } from '../../lib/posts';
import Badge from '../Badge.astro';
import Card from '../Card.astro';
import DateBadges from './DateBadges.astro';
export interface Props {
post: Post;
/** `row`: blog list (cover beside text on md+); `stack`: grid card. */
layout?: 'row' | 'stack';
headingLevel?: 2 | 3;
/** Eager-load the cover (first card above the fold). */
priority?: boolean;
}
const { post, layout = 'row', headingLevel = 2, priority = false } = Astro.props;
const { title, description, cover, coverAlt, pubDate, updatedDate } = post.data;
const category = categoryOf(post);
const Heading = `h${headingLevel}` as 'h2' | 'h3';
const row = layout === 'row';
---
<Card
interactive
class:list={['w-full', row && 'md:grid md:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]']}
>
<div
class:list={['bg-bg-alt relative aspect-[1200/630] overflow-hidden', row && 'md:aspect-auto']}
>
{cover ? (
<Image
src={cover}
alt={coverAlt ?? ''}
widths={[400, 640, 960]}
sizes={row ? '(min-width: 768px) 320px, 100vw' : '(min-width: 1024px) 400px, 100vw'}
loading={priority ? 'eager' : 'lazy'}
fetchpriority={priority ? 'high' : 'auto'}
class="h-full w-full object-cover"
/>
) : (
<div class="flex h-full min-h-40 items-center justify-center" aria-hidden="true">
<Icon name={category.icon} class="text-fg/40 size-16" />
</div>
)}
</div>
<div class="flex flex-1 flex-col gap-3 p-6">
<Badge variant="category" href={categoryUrl(category.slug)} class="relative z-10">
{category.name}
</Badge>
<Heading class:list={['leading-tight font-bold', row ? 'text-2xl' : 'text-xl']}>
<a href={postUrl(post)} class="after:absolute after:inset-0 hover:underline">
{title}
</a>
</Heading>
<p class="text-fg-muted">{description}</p>
<DateBadges
pubDate={pubDate}
updatedDate={updatedDate}
minutes={readingMinutes(post)}
class="mt-auto pt-2"
/>
</div>
</Card>
+101
View File
@@ -0,0 +1,101 @@
---
// Pagefind search (index is built after `astro build`; unavailable in `astro dev`).
import { Icon } from 'astro-icon/components';
import { blog } from '../../lib/data';
---
<div
data-search
data-no-results={blog.sidebar.searchNoResults}
data-results={blog.sidebar.searchResults}
data-unavailable={blog.sidebar.searchUnavailable}
>
<form role="search" class="relative">
<label for="search-input" class="sr-only">
{blog.sidebar.searchLabel}
</label>
<Icon
name="tabler:search"
class="text-fg-muted pointer-events-none absolute top-1/2 left-3 size-5 -translate-y-1/2"
aria-hidden="true"
/>
<input
id="search-input"
type="search"
autocomplete="off"
placeholder={blog.sidebar.searchPlaceholder}
class="border-fg bg-surface placeholder:text-fg-muted w-full border py-2.5 pr-3 pl-10"
/>
</form>
<p data-search-status aria-live="polite" class="text-fg-muted mt-2 text-sm empty:hidden"></p>
<ul data-search-results class="mt-3 flex flex-col gap-3 empty:hidden"></ul>
</div>
<script>
type PagefindResult = {
url: string;
excerpt: string;
meta: { title?: string };
};
type Pagefind = {
options: (o: Record<string, unknown>) => Promise<void>;
debouncedSearch: (
q: string,
o?: unknown,
ms?: number,
) => Promise<{ results: { data: () => Promise<PagefindResult> }[] } | null>;
};
for (const root of document.querySelectorAll<HTMLElement>('[data-search]')) {
const input = root.querySelector('input')!;
root.querySelector('form')!.addEventListener('submit', (e) => e.preventDefault());
const status = root.querySelector<HTMLElement>('[data-search-status]')!;
const list = root.querySelector<HTMLUListElement>('[data-search-results]')!;
let pagefind: Pagefind | null | undefined;
const load = async () => {
if (pagefind !== undefined) return pagefind;
try {
const url = '/pagefind/pagefind.js';
pagefind = (await import(/* @vite-ignore */ url)) as Pagefind;
await pagefind.options({ excerptLength: 20 });
} catch {
pagefind = null;
}
return pagefind;
};
input.addEventListener('focus', () => void load(), { once: true });
input.addEventListener('input', async () => {
const q = input.value.trim();
const pf = await load();
if (!pf) {
status.textContent = root.dataset.unavailable ?? '';
return;
}
const search = await pf.debouncedSearch(q, {}, 250);
if (search === null) return; // superseded by a newer keystroke
list.replaceChildren();
if (!q) {
status.textContent = '';
return;
}
const items = await Promise.all(search.results.slice(0, 6).map((r) => r.data()));
status.textContent = items.length
? (root.dataset.results ?? '').replace('{n}', String(search.results.length))
: (root.dataset.noResults ?? '');
for (const item of items) {
const li = document.createElement('li');
const a = document.createElement('a');
a.href = item.url;
a.className = 'font-bold hover:underline';
a.textContent = item.meta.title ?? item.url;
const p = document.createElement('p');
p.className = 'text-fg-muted text-sm [&_mark]:bg-khaki [&_mark]:text-ink';
p.innerHTML = item.excerpt; // Pagefind escapes content; only <mark> is HTML
li.append(a, p);
list.append(li);
}
});
}
</script>
+82
View File
@@ -0,0 +1,82 @@
---
import { Icon } from 'astro-icon/components';
import { blog, site } from '../../lib/data';
export interface Props {
url: string;
title: string;
}
const { url, title } = Astro.props;
const fill = (tpl: string) =>
tpl.replace('{url}', encodeURIComponent(url)).replace('{title}', encodeURIComponent(title));
const btn =
'border-line hover:border-fg hover:bg-fg hover:text-bg inline-flex size-11 cursor-pointer items-center justify-center border transition-colors';
---
<div data-share data-url={url} data-title={title} data-copied={blog.post.copied}>
<p class="font-display mb-3 text-sm font-bold tracking-wide uppercase" id="share-label">
{blog.post.shareLabel}
</p>
<ul class="flex flex-wrap gap-2" aria-labelledby="share-label">
{blog.share.map((s) => (
<li>
<a
href={fill(s.url)}
target={s.id === 'email' ? undefined : '_blank'}
rel={s.id === 'email' ? undefined : 'noopener noreferrer'}
title={s.label}
class={btn}
>
<Icon name={s.icon} class="size-5" aria-hidden="true" />
<span class="sr-only">
{s.label}
{s.id !== 'email' && ` ${site.ui.newTab}`}
</span>
</a>
</li>
))}
<li>
<button type="button" data-share-copy title={blog.post.copyLink} class={btn}>
<Icon name="tabler:link" class="size-5" aria-hidden="true" />
<span class="sr-only">{blog.post.copyLink}</span>
</button>
</li>
<li hidden data-share-native-item>
<button type="button" data-share-native title={blog.post.nativeShare} class={btn}>
<Icon name="tabler:share" class="size-5" aria-hidden="true" />
<span class="sr-only">{blog.post.nativeShare}</span>
</button>
</li>
</ul>
<p
data-share-status
role="status"
aria-live="polite"
class="text-accent-text mt-2 min-h-5 text-sm font-semibold"
></p>
</div>
<script>
for (const root of document.querySelectorAll<HTMLElement>('[data-share]')) {
const { url = location.href, title = document.title, copied = '' } = root.dataset;
const status = root.querySelector<HTMLElement>('[data-share-status]')!;
root.querySelector('[data-share-copy]')?.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(url);
status.textContent = copied;
setTimeout(() => (status.textContent = ''), 2500);
} catch {
prompt('', url);
}
});
if (typeof navigator.share === 'function') {
root.querySelector<HTMLElement>('[data-share-native-item]')!.hidden = false;
root.querySelector('[data-share-native]')!.addEventListener('click', () => {
navigator.share({ url, title }).catch(() => {});
});
}
}
</script>
+117
View File
@@ -0,0 +1,117 @@
---
import { blog } from '../../lib/data';
import {
categoryCounts,
categoryUrl,
featuredPosts,
postUrl,
tagCounts,
tagUrl,
type Post,
} from '../../lib/posts';
import SearchBox from './SearchBox.astro';
import { Icon } from 'astro-icon/components';
export interface Props {
/** All published posts (not just the current page). */
posts: Post[];
/** Highlights the current category in the list. */
activeCategory?: string;
activeTag?: string;
}
const { posts, activeCategory, activeTag } = Astro.props;
const cats = categoryCounts(posts);
const featured = featuredPosts(posts);
const tags = tagCounts(posts);
const max = Math.max(1, ...tags.map((t) => t.count));
// 4 size steps by relative frequency.
const tagSize = (count: number) =>
['text-sm', 'text-base', 'text-lg', 'text-xl'][Math.min(3, Math.floor((count / max) * 3.999))];
const box = 'border-line bg-surface border p-5';
const title = 'font-display mb-4 flex items-center gap-3 text-sm font-bold tracking-wide uppercase';
---
<aside aria-label={blog.sidebar.label} class="flex flex-col gap-6">
<section class={box} aria-labelledby="sb-search">
<h2 id="sb-search" class={title}>
{blog.sidebar.search}
<span aria-hidden="true" class="bg-accent h-0.5 w-8"></span>
</h2>
<SearchBox />
</section>
{cats.length > 0 && (
<section class={box} aria-labelledby="sb-categories">
<h2 id="sb-categories" class={title}>
{blog.sidebar.categories}
<span aria-hidden="true" class="bg-accent h-0.5 w-8"></span>
</h2>
<ul class="flex flex-col">
{cats.map(({ category, count }) => (
<li>
<a
href={categoryUrl(category.slug)}
aria-current={activeCategory === category.slug ? 'page' : undefined}
class="border-line hover:text-accent-text aria-[current=page]:text-accent-text flex items-center gap-2 border-b py-2 aria-[current=page]:font-bold"
>
<Icon name={category.icon} class="size-5 shrink-0" aria-hidden="true" />
<span class="flex-1">{category.name}</span>
<span class="font-display text-fg-muted text-sm font-bold">{count}</span>
</a>
</li>
))}
</ul>
</section>
)}
{featured.length > 0 && (
<section class={box} aria-labelledby="sb-featured">
<h2 id="sb-featured" class={title}>
{blog.sidebar.featured}
<span aria-hidden="true" class="bg-accent h-0.5 w-8"></span>
</h2>
<ol data-featured class="flex flex-col gap-4">
{featured.map((post, i) => (
<li class="flex gap-3">
<span
aria-hidden="true"
class="font-display text-accent-text text-2xl leading-none font-black"
>
{String(i + 1).padStart(2, '0')}
</span>
<a href={postUrl(post)} class="leading-snug font-semibold hover:underline">
{post.data.title}
</a>
</li>
))}
</ol>
</section>
)}
{tags.length > 0 && (
<section class={box} aria-labelledby="sb-tags">
<h2 id="sb-tags" class={title}>
{blog.sidebar.tags}
<span aria-hidden="true" class="bg-accent h-0.5 w-8"></span>
</h2>
<ul class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
{tags.map((t) => (
<li>
<a
href={tagUrl(t.tag)}
aria-current={activeTag === t.slug ? 'page' : undefined}
class:list={[
'hover:text-accent-text aria-[current=page]:text-accent-text font-semibold hover:underline',
tagSize(t.count),
]}
>
#{t.tag}
<span class="sr-only">({t.count})</span>
</a>
</li>
))}
</ul>
</section>
)}
</aside>
+29
View File
@@ -0,0 +1,29 @@
---
import type { MarkdownHeading } from 'astro';
import { blog } from '../../lib/data';
export interface Props {
headings: MarkdownHeading[];
}
const items = Astro.props.headings.filter((h) => h.depth === 2 || h.depth === 3);
---
{items.length >= 2 && (
<details open class="border-line bg-surface group border p-5">
<summary class="font-display cursor-pointer text-sm font-bold tracking-wide uppercase">
{blog.post.toc}
</summary>
<nav aria-label={blog.post.toc} class="mt-4">
<ol class="flex flex-col gap-2">
{items.map((h) => (
<li class:list={[h.depth === 3 && 'pl-5 text-sm']}>
<a href={`#${h.slug}`} class="decoration-accent underline-offset-4 hover:underline">
{h.text}
</a>
</li>
))}
</ol>
</nav>
</details>
)}