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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
)}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
)}
|
||||
Reference in New Issue
Block a user