Building a tag system in Astro
Original post: Building a tag system in Astro Series: Part of How this blog was built — documenting every decision that shaped this site. Tags do more than connect related posts. They create a second navigation system. /tags overview, paginated tag archive pages, and a sidebar browser on The key constraint is consistency. If each surface counts tags differently, Diagram fallback for Dev.to. View the canonical article for the original SVG: https://sourcier.uk/blog/tag-system-astro Every tag is displayed as-is, but routed via a URL-safe slug. A small utility in src/utils/tags.ts handles that conversion: export function tagSlug(tag: string): string { return tag .toLowerCase() .replace(/\s+/g, "-") .replace(/[^a-z0-9-]/g, ""); } That turns "web development" into "web-development", "Node.js" into "nodejs", and "C#" into "c". It is intentionally small. A more generic The important detail is reuse. The cloud component, sidebar, tags index, and tag Counting tags is trivial. Counting the right tags is the subtle part. This site does not let each tag surface invent its own visibility rules. It import { isPublished } from "../utils/drafts"; const allPosts = (await getCollection("posts")).filter(isPublished); const tagCounts = new Map(); for (const post of allPosts) { for (const tag of post.data.tags) { tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1); } } That matters because isPublished() hides both drafts and future-dated posts SHOW_DRAFTS=true. The weighted cloud, ring layout, tag archives, and Right now the counting logic is repeated across several files. That is still a getTagCounts() helper would be The weighted cloud no longer lives on the homepage. It sits on the blog archive: /blog and the paginated /blog/page/[page] pages both render the same import BlogTagCloud from "../../components/BlogTagCloud.astro"; // ... Inside BlogTagCloud.astro, counts are converted into three visual tiers: function tier(count: number): number { if (maxCount === minCount) return 2; const ratio = (count - minCount) / (maxCount - minCount); if (ratio >= 0.66) return 3; if (ratio >= 0.33) return 2; return 1; } Those tiers map to modifier classes such as tag-cloud__pill--tier-1, tag-cloud__pill--tier-2, and tag-cloud__pill--tier-3. A stepped system is deliberate here. Continuous /tags page, but in a compact cloud Each pill also includes a small count badge, so someone hovering or scanning the /tags /tags The /tags page is the more exploratory view. It combines two separate signals: Font size scales continuously with tag frequency. Tags are placed on concentric rings, with the most common topic in the centre and less common topics pushed outward. The scaling and ring layout are both calculated at build time in the page const MIN_REM = 1.1; const MAX_REM = 4; function scale(count: number): number { if (maxCount === minCount) return (MIN_REM + MAX_REM) / 2; return ( MIN_REM + ((count - minCount) / (maxCount - minCount)) * (MAX_REM - MIN_REM) ); } const ringConfig = [ { radiusPct: 0, max: 1, startDeg: 0 }, { radiusPct: 18, max: 4, startDeg: -90 }, { radiusPct: 35, max: 7, startDeg: -50 }, { radiusPct: 48, max: 12, startDeg: 10 }, ]; After sorting tags by count descending, the page fills the rings from the inside const angleDeg = radiusPct === 0 ? 0 : startDeg + (360 * i) / items.length; const rad = (angleDeg * Math.PI) / 180; const x = radiusPct === 0 ? 50 : 50 + radiusPct * Math.cos(rad); const y = radiusPct === 0 ? 50 : 50 + radiusPct * Math.sin(rad); That produces an SVG-like layout without SVG. Each tag is still a normal anchor, The accessibility model matters here too. The circular cloud is decorative and aria-hidden, while a plain list remains in the accessibility tree and Each topic gets a clean first-page URL at /tags/[tag] and subsequent archive /tags/[tag]/2, /tags/[tag]/3, and so on. Rather than using Astro's paginate() helper, the current implementation keeps src/pages/tags/[tag]/index.astro handles page 1. src/pages/tags/[tag]/[page].astro emits pages 2 and above. The second file builds its paths manually: export async function getStaticPaths() { const PAGE_SIZE = 9; const allPosts = (await getCollection("posts")).filter(isPublished); const tagMap = new Map(); for (const post of allPosts) { for (const tag of post.data.tags) { const slug = tagSlug(tag); if (!tagMap.has(slug)) tagMap.set(slug, []); tagMap.get(slug)!.push(post); } } const paths: object[] = []; for (const [slug, posts] of tagMap.entries()) { const totalPages = Math.ceil(posts.length / PAGE_SIZE); const label = posts[0].data.tags.find((t) => tagSlug(t) === slug) ?? slug; for (let pageNum = 2; pageNum b - a) .map(([tag, count]) => ({ tag, count, slug: tagSlug(tag) })); The sidebar only matters because the post layout mounts it alongside the table import TagsSidebar from "../components/TagsSidebar.astro"; {/* other sidebar blocks */} That keeps topic browsing available even when someone lands deep on an /tags, which keeps the underlying implementation short The interesting part of this tag system is not any single surface. It is the If you want to inspect the finished implementation end to end, expand the src/utils/drafts.ts and is src/utils/tags.ts export function tagSlug(tag: string): string { return tag .toLowerCase() .replace(/\s+/g, "-") .replace(/[^a-z0-9-]/g, ""); } src/components/BlogTagCloud.astro --- import { getCollection } from "astro:content"; import { isPublished } from "../utils/drafts"; import { tagSlug } from "../utils/tags"; const allPosts = (await getCollection("posts")).filter(isPublished); const tagCounts = new Map(); for (const post of allPosts) { for (const tag of post.data.tags) { tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1); } } const counts = [...tagCounts.values()]; const minCount = Math.min(...counts); const maxCount = Math.max(...counts); function tier(count: number): number { if (maxCount === minCount) return 2; const ratio = (count - minCount) / (maxCount - minCount); if (ratio >= 0.66) return 3; if (ratio >= 0.33) return 2; return 1; } const tags = [...tagCounts.entries()] .sort(([, a], [, b]) => b - a) .map(([tag, count]) => ({ tag, count, slug: tagSlug(tag), tier: tier(count), })); --- Topics Browse by topic Jump straight into the subjects you care about without paging through the full archive first. All topics → { tags.map(({ tag, count, slug, tier }) => ( {tag} {count} )) } .tag-cloud { padding: 0 1.5rem; } .tag-cloud__panel { position: relative; overflow: hidden; padding: clamp(1.75rem, 3.5vw, 2.35rem); background: radial-gradient( circle at top right, color-mix(in srgb, var(--accent-secondary) 14%, transparent) 0%, transparent 34% ), radial-gradient( circle at left bottom, var(--accent-primary-alpha-08) 0%, transparent 30% ), linear-gradient( 145deg, color-mix( in srgb, var(--surface-page) 88%, var(--accent-secondary) 12% ), var(--surface-elevated) ); } .tag-cloud__header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 1.75rem; gap: 1rem; @media (max-width: 639px) { flex-direction: column; align-items: flex-start; } } .tag-cloud__intro { min-width: 0; } .tag-cloud__title { margin: 0; font-family: "Barlow Condensed", sans-serif; font-size: clamp(1.8rem, 4vw, 2.5rem); line-height: 1.05; text-transform: uppercase; color: var(--text-primary); } .tag-cloud__description { margin: 0.5rem 0 0; max-width: 48ch; line-height: 1.65; color: var(--text-muted); } .tag-cloud__all-link { display: inline-flex; align-items: center; justify-content: center; min-height: 2.85rem; padding: 0.75rem 1.05rem; border: 1px solid color-mix(in srgb, var(--accent-secondary) 20%, transparent); border-radius: var(--radius-pill); background: color-mix( in srgb, var(--accent-secondary) 9%, var(--surface-elevated) ); font-family: "Barlow Condensed", sans-serif; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); text-decoration: none; transition: color 0.15s ease, border-color 0.15s ease, transform 0.15s ease; &:hover { color: var(--accent-secondary); border-color: var(--accent-secondary-alpha-28); transform: translateY(-1px); } @media (min-width: 640px) { align-self: center; } } .tag-cloud__list { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; } .tag-cloud__pill { display: inline-flex; align-items: center; gap: 0.4rem; text-decoration: none; font-family: "Barlow Condensed", sans-serif; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; border: 1.5px solid var(--border-subtle); border-radius: var(--radius-pill); color: var(--text-muted); background-color: color-mix( in srgb, var(--surface-elevated) 78%, transparent ); box-shadow: inset 0 1px 0 var(--text-on-strong-alpha-45); transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease, transform 0.15s ease; &:hover { color: var(--accent-secondary); border-color: var(--accent-secondary-alpha-26); background-color: color-mix( in srgb, var(--accent-secondary) 9%, var(--surface-elevated) ); transform: translateY(-1px); } &--tier-1 { font-size: 0.74rem; padding: 0.3rem 0.6rem; } &--tier-2 { font-size: 0.9rem; padding: 0.35rem 0.72rem; } &--tier-3 { font-size: 1.04rem; padding: 0.42rem 0.82rem; border-width: 2px; color: var(--text-primary); border-color: var(--accent-secondary-alpha-18); } } .tag-cloud__count { font-size: 0.65em; font-weight: 700; color: var(--accent-secondary); background-color: color-mix( in srgb, var(--accent-secondary) 12%, transparent ); padding: 0.05rem 0.35rem; border-radius: var(--radius-pill); transition: background-color 0.15s ease, color 0.15s ease; .tag-cloud__pill:hover & { background-color: var(--accent-secondary-alpha-16); color: var(--accent-secondary); } } src/components/TagsSidebar.astro --- import { getCollection } from "astro:content"; import { isPublished } from "../utils/drafts"; import { tagSlug } from "../utils/tags"; const allPosts = (await getCollection("posts")).filter(isPublished); const tagCounts = new Map(); for (const post of allPosts) { for (const tag of post.data.tags) { tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1); } } const tags = [...tagCounts.entries()] .sort(([, a], [, b]) => b - a) .map(([tag, count]) => ({ tag, count, slug: tagSlug(tag) })); --- Browse topics { tags.map(({ tag, count, slug }) => ( {tag} {count} )) } All tags → .tags-sidebar { margin-top: 2rem; margin-bottom: 2rem; } .tags-sidebar__heading { font-family: "Barlow Condensed", sans-serif; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: var(--accent-primary); padding-top: 0.75rem; border-top: 2px solid var(--accent-primary); margin-bottom: 0.75rem; } .tags-sidebar__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0; } .tags-sidebar__tag { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; padding: 0.45rem 0; border-bottom: 1px solid var(--border-subtle); text-decoration: none; color: var(--text-muted); transition: color 0.15s ease, padding-left 0.15s ease; &:hover { color: var(--accent-primary); padding-left: 0.35rem; } } .tags-sidebar__name { font-family: "Barlow Condensed", sans-serif; font-size: 0.875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; } .tags-sidebar__count { font-family: "Barlow Condensed", sans-serif; font-size: 0.7rem; font-weight: 700; color: var(--text-muted); background-color: var(--border-subtle); border-radius: var(--radius-tight); padding: 0.1rem 0.4rem; min-width: 1.4rem; text-align: center; transition: background-color 0.15s ease, color 0.15s ease; .tags-sidebar__tag:hover & { background-color: rgba(var(--accent-primary-rgb), 0.12); color: var(--accent-primary); } } .tags-sidebar__all { display: block; margin-top: 1rem; font-family: "Barlow Condensed", sans-serif; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-muted); text-decoration: none; transition: color 0.15s ease; &:hover { color: var(--accent-primary); } } src/pages/tags/index.astro --- import { getCollection } from "astro:content"; import { isPublished } from "../../utils/drafts"; import BaseLayout from "../../layouts/BaseLayout.astro"; import PageHero from "../../components/PageHero.astro"; import MailingListCTA from "../../components/MailingListCTA.astro"; import { tagSlug } from "../../utils/tags"; const allPosts = (await getCollection("posts")).filter(isPublished); const tagCounts = new Map(); for (const post of allPosts) { for (const tag of post.data.tags) { tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1); } } const counts = [...tagCounts.values()]; const minCount = Math.min(...counts); const maxCount = Math.max(...counts); const MIN_REM = 1.1; const MAX_REM = 4; function scale(count: number): number { if (maxCount === minCount) return (MIN_REM + MAX_REM) / 2; return ( MIN_REM + ((count - minCount) / (maxCount - minCount)) * (MAX_REM - MIN_REM) ); } const sorted = [...tagCounts.entries()] .sort(([, a], [, b]) => b - a) .map(([tag, count]) => ({ tag, count, slug: tagSlug(tag), fontSize: scale(count), })); const ringConfig = [ { radiusPct: 0, max: 1, startDeg: 0 }, { radiusPct: 18, max: 4, startDeg: -90 }, { radiusPct: 35, max: 7, startDeg: -50 }, { radiusPct: 48, max: 12, startDeg: 10 }, ]; type TagPos = { tag: string; count: number; slug: string; fontSize: number; x: number; y: number; ringIdx: number; }; const positioned: TagPos[] = []; let si = 0; for (let ringIdx = 0; ringIdx < ringConfig.length; ringIdx++) { if (si >= sorted.length) break; const { radiusPct, max, startDeg } = ringConfig[ringIdx]; const items = sorted.slice(si, si + max); si += items.length; items.forEach((item, i) => { const angleDeg = radiusPct === 0 ? 0 : startDeg + (360 * i) / items.length; const rad = (angleDeg * Math.PI) / 180; const x = radiusPct === 0 ? 50 : 50 + radiusPct * Math.cos(rad); const y = radiusPct === 0 ? 50 : 50 + radiusPct * Math.sin(rad); positioned.push({ ...item, x, y, ringIdx }); }); } function seededShuffle(arr: T[]): T[] { const out = [...arr]; let seed = 42; const rand = () => { seed = (seed * 1664525 + 1013904223) & 0xffffffff; return (seed >>> 0) / 0xffffffff; }; for (let i = out.length - 1; i > 0; i--) { const j = Math.floor(rand() * (i + 1)); [out[i], out[j]] = [out[j], out[i]]; } return out; } const shuffled = seededShuffle(sorted); const totalTags = sorted.length; const totalPosts = allPosts.length; const ringStyle = (ringIdx: number) => { if (ringIdx === 0) return "color: var(--accent-primary); opacity: 1"; if (ringIdx === 1) return "opacity: 1"; if (ringIdx === 2) return "opacity: 0.85"; return "opacity: 0.7"; }; --- Topic map Explore the writing by theme Jump straight into the ideas that show up most often across the blog, or skim the full map below if you want to browse more broadly. Published {totalPosts} Topics {totalTags} Each topic links to a collection of related posts. Larger tags mean I write about that area more often, so the subjects I return to most sit closer to the centre. fewer posts → more posts { positioned.map(({ tag, slug, fontSize, x, y, ringIdx }) => ( {tag} )) } { shuffled.map(({ tag, count, slug, fontSize }) => ( {tag} )) } .tag-summary { padding: 0 1.5rem; } .tag-summary__panel { display: grid; gap: 1.25rem; border-top-color: var(--accent-secondary); background: radial-gradient( circle at top right, color-mix(in srgb, var(--accent-secondary) 14%, transparent) 0%, transparent 34% ), linear-gradient( 150deg, color-mix(in srgb, var(--accent-secondary) 8%, var(--surface-elevated)) 0%, var(--surface-elevated) 62% ), var(--surface-elevated); @media (min-width: 768px) { grid-template-columns: minmax(0, 1.5fr) auto; gap: 2rem; align-items: center; } } .tag-summary__eyebrow { margin: 0 0 0.65rem; font-family: "Barlow Condensed", sans-serif; font-size: 0.8rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent-secondary); } .tag-summary__title { margin: 0; font-family: "Barlow Condensed", sans-serif; font-size: clamp(2rem, 4vw, 2.8rem); line-height: 0.98; text-transform: uppercase; color: var(--text-primary); } .tag-summary__text { margin: 0.85rem 0 0; max-width: 58ch; line-height: 1.7; color: var(--text-muted); } .tag-summary__stats { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.75rem; } .tag-summary__stat { display: grid; gap: 0.35rem; min-width: 9rem; padding: 1rem 1.1rem; border: 1px solid var(--accent-secondary-alpha-16); border-radius: var(--radius-soft); background: color-mix( in srgb, var(--accent-secondary) 8%, var(--surface-elevated) ); } .tag-summary__label { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-muted); } .tag-summary__value { font-family: "Barlow Condensed", sans-serif; font-size: clamp(1.55rem, 4vw, 2.1rem); font-weight: 800; line-height: 1; text-transform: uppercase; color: var(--text-primary); } .cloud-section { padding-top: 0; padding-bottom: 0; } .cloud-surface { overflow: hidden; padding: clamp(1.5rem, 3vw, 2rem); background: radial-gradient( circle at top right, color-mix(in srgb, var(--accent-secondary) 10%, transparent) 0%, transparent 34% ), linear-gradient( 150deg, color-mix(in srgb, var(--accent-primary) 3%, var(--surface-elevated)) 0%, var(--surface-elevated) 66% ), var(--surface-elevated); } .cloud-intro { display: flex; flex-direction: column; gap: 1.5rem; margin-bottom: 2.5rem; padding-bottom: 2rem; border-bottom: 1px solid var(--border-subtle); @media (min-width: 768px) { flex-direction: row; align-items: flex-start; justify-content: space-between; gap: 4rem; } } .cloud-intro__text { margin: 0; font-size: 1rem; line-height: 1.75; color: var(--text-muted); max-width: 52ch; } .cloud-legend { flex-shrink: 0; display: flex; align-items: center; gap: 1rem; } .cloud-legend__arrow { color: var(--accent-secondary); font-size: 1.25rem; } .cloud-legend__example { font-family: "Barlow Condensed", sans-serif; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-primary); } .cloud-legend__example--sm { font-size: 0.875rem; opacity: 0.45; } .cloud-legend__example--lg { font-size: 2rem; color: var(--accent-secondary); } .cloud-circle-wrap { display: none; justify-content: center; margin: 0.5rem 0 2rem; @media (min-width: 640px) { display: flex; } } .cloud-circle { position: relative; width: min(88vw, 580px); aspect-ratio: 1; &::before { content: ""; position: absolute; inset: 0; border-radius: 50%; border: 1px solid var(--border-subtle); pointer-events: none; } &::after { content: ""; position: absolute; inset: 22%; border-radius: 50%; border: 1px dashed var(--accent-secondary-alpha-18); pointer-events: none; } } .cloud-circle__tag { position: absolute; transform: translate(-50%, -50%) scale(1); font-family: "Barlow Condensed", sans-serif; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; text-decoration: none; color: var(--text-primary); white-space: nowrap; transition: color 0.2s ease, opacity 0.2s ease, transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); &:hover { color: var(--accent-secondary) !important; opacity: 1 !important; transform: translate(-50%, -50%) scale(1.18); z-index: 1; } } .cloud-circle:has(.cloud-circle__tag:hover) .cloud-circle__tag:not(:hover) { opacity: 0.15 !important; transform: translate(-50%, -50%) scale(0.95); } .cloud-list { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: 0.75rem 1.5rem; list-style: none; margin: 1rem 0; padding: 0; @media (min-width: 640px) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } } .cloud-list__tag { font-family: "Barlow Condensed", sans-serif; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-primary); text-decoration: none; opacity: 0.75; white-space: nowrap; transition: color 0.15s ease, opacity 0.15s ease; &:hover { color: var(--accent-secondary); opacity: 1; } } src/pages/tags/[tag]/index.astro --- import { getCollection } from "astro:content"; import { isPublished } from "../../../utils/drafts"; import BaseLayout from "../../../layouts/BaseLayout.astro"; import PageHero from "../../../components/PageHero.astro"; import BlogGrid from "../../../components/BlogGrid.astro"; import MailingListCTA from "../../../components/MailingListCTA.astro"; import { tagSlug } from "../../../utils/tags"; export async function getStaticPaths() { const allPosts = (await getCollection("posts")).filter(isPublished); const tagMap = new Map(); for (const post of allPosts) { for (const tag of post.data.tags) { const slug = tagSlug(tag); if (!tagMap.has(slug)) tagMap.set(slug, []); tagMap.get(slug)!.push(post); } } return [...tagMap.entries()].map(([slug, posts]) => { const label = posts[0].data.tags.find((t) => tagSlug(t) === slug) ?? slug; return { params: { tag: slug }, props: { label } }; }); } const PAGE_SIZE = 9; const { tag } = Astro.params; const { label } = Astro.props; const allTagPosts = (await getCollection("posts")) .filter( (post) => isPublished(post) && post.data.tags.some((t) => tagSlug(t) === tag), ) .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()); const totalPages = Math.ceil(allTagPosts.length / PAGE_SIZE); const posts = allTagPosts.slice(0, PAGE_SIZE); const nextUrl = totalPages > 1 ? `/tags/${tag}/2` : null; const postCountLabel = allTagPosts.length === 1 ? "1 post" : `${allTagPosts.length} posts`; --- Topic archive {postCountLabel} about {label} This archive keeps every article filed under this topic in one place, newest first, so you can stay with one theme without jumping around the rest of the site. All topics Latest writing 1 ? `, across ${totalPages} pages.` : "."}`} /> .topic-overview { padding: 0 1.5rem; } .topic-overview__panel { display: grid; gap: 1.25rem; border-top-color: var(--accent-secondary); background: radial-gradient( circle at top right, color-mix(in srgb, var(--accent-secondary) 14%, transparent) 0%, transparent 34% ), linear-gradient( 150deg, color-mix(in srgb, var(--accent-secondary) 8%, var(--surface-elevated)) 0%, var(--surface-elevated) 62% ), var(--surface-elevated); @media (min-width: 768px) { grid-template-columns: minmax(0, 1.45fr) auto; gap: 2rem; align-items: center; } } .topic-overview__eyebrow { margin: 0 0 0.65rem; font-family: "Barlow Condensed", sans-serif; font-size: 0.8rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent-secondary); } .topic-overview__title { margin: 0; font-family: "Barlow Condensed", sans-serif; font-size: clamp(1.9rem, 4vw, 2.7rem); line-height: 0.98; text-transform: uppercase; color: var(--text-primary); } .topic-overview__text { margin: 0.85rem 0 0; max-width: 58ch; line-height: 1.7; color: var(--text-muted); } .topic-overview__actions { display: flex; flex-wrap: wrap; gap: 0.75rem; } .topic-overview__link { display: inline-flex; align-items: center; justify-content: center; min-height: 2.85rem; padding: 0.7rem 1rem; border: 1px solid var(--accent-secondary-alpha-18); border-radius: var(--radius-pill); background: color-mix( in srgb, var(--accent-secondary) 8%, var(--surface-elevated) ); font-family: "Barlow Condensed", sans-serif; font-size: 0.85rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--accent-secondary); text-decoration: none; transition: border-color 0.15s ease, transform 0.15s ease; &:hover { border-color: var(--accent-secondary-alpha-32); transform: translateY(-1px); } } .topic-overview__link--secondary { border-color: var(--accent-primary-alpha-22); background: color-mix( in srgb, var(--accent-primary) 8%, var(--surface-elevated) ); color: var(--accent-primary); } src/pages/tags/[tag]/[page].astro --- import { getCollection } from "astro:content"; import { isPublished } from "../../../utils/drafts"; import BaseLayout from "../../../layouts/BaseLayout.astro"; import PageHero from "../../../components/PageHero.astro"; import BlogGrid from "../../../components/BlogGrid.astro"; import MailingListCTA from "../../../components/MailingListCTA.astro"; import { tagSlug } from "../../../utils/tags"; export async function getStaticPaths() { const PAGE_SIZE = 9; const allPosts = (await getCollection("posts")).filter(isPublished); const tagMap = new Map(); for (const post of allPosts) { for (const tag of post.data.tags) { const slug = tagSlug(tag); if (!tagMap.has(slug)) tagMap.set(slug, []); tagMap.get(slug)!.push(post); } } const paths: object[] = []; for (const [slug, posts] of tagMap.entries()) { const totalPages = Math.ceil(posts.length / PAGE_SIZE); const label = posts[0].data.tags.find((t) => tagSlug(t) === slug) ?? slug; for (let pageNum = 2; pageNum <= totalPages; pageNum++) { paths.push({ params: { tag: slug, page: String(pageNum) }, props: { label }, }); } } return paths; } const PAGE_SIZE = 9; const { tag, page } = Astro.params; const { label } = Astro.props; const currentPage = Number(page); const allTagPosts = (await getCollection("posts")) .filter( (post) => isPublished(post) && post.data.tags.some((t) => tagSlug(t) === tag), ) .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()); const totalPages = Math.ceil(allTagPosts.length / PAGE_SIZE); const posts = allTagPosts.slice( (currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE, ); const prevUrl = currentPage === 2 ? `/tags/${tag}` : `/tags/${tag}/${currentPage - 1}`; const nextUrl = currentPage < totalPages ? `/tags/${tag}/${currentPage + 1}` : null; const postCountLabel = allTagPosts.length === 1 ? "1 post" : `${allTagPosts.length} posts`; --- Topic archive Page {currentPage} of {totalPages} {postCountLabel} filed under {label}. This page keeps you inside the same topic while you move through older entries in the archive. Topic overview Latest writing .topic-overview { padding: 0 1.5rem; } .topic-overview__panel { display: grid; gap: 1.25rem; border-top-color: var(--accent-secondary); background: radial-gradient( circle at top right, color-mix(in srgb, var(--accent-secondary) 14%, transparent) 0%, transparent 34% ), linear-gradient( 150deg, color-mix(in srgb, var(--accent-secondary) 8%, var(--surface-elevated)) 0%, var(--surface-elevated) 62% ), var(--surface-elevated); @media (min-width: 768px) { grid-template-columns: minmax(0, 1.45fr) auto; gap: 2rem; align-items: center; } } .topic-overview__eyebrow { margin: 0 0 0.65rem; font-family: "Barlow Condensed", sans-serif; font-size: 0.8rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent-secondary); } .topic-overview__title { margin: 0; font-family: "Barlow Condensed", sans-serif; font-size: clamp(1.9rem, 4vw, 2.7rem); line-height: 0.98; text-transform: uppercase; color: var(--text-primary); } .topic-overview__text { margin: 0.85rem 0 0; max-width: 58ch; line-height: 1.7; color: var(--text-muted); } .topic-overview__actions { display: flex; flex-wrap: wrap; gap: 0.75rem; } .topic-overview__link { display: inline-flex; align-items: center; justify-content: center; min-height: 2.85rem; padding: 0.7rem 1rem; border: 1px solid var(--accent-secondary-alpha-18); border-radius: var(--radius-pill); background: color-mix( in srgb, var(--accent-secondary) 8%, var(--surface-elevated) ); font-family: "Barlow Condensed", sans-serif; font-size: 0.85rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--accent-secondary); text-decoration: none; transition: border-color 0.15s ease, transform 0.15s ease; &:hover { border-color: var(--accent-secondary-alpha-32); transform: translateY(-1px); } } .topic-overview__link--secondary { border-color: var(--accent-primary-alpha-22); background: color-mix( in srgb, var(--accent-primary) 8%, var(--surface-elevated) ); color: var(--accent-primary); } You can browse the rest of the site code in the web-sourcier.uk repository.
