initial: four-collection content pipeline for unique.rzen.dev

A small Astro + MDX blog for delightful, daily-use finds. The architecture
separates capture from publication and supports reuse:

- find/    captured items, hidden from indexes
- finds/   editorial superposts referencing find slugs in a grid
- reviews/ full-length writeups; can graduate from a find via fromFind
- posts/   regular essays

Three project-local skills under .claude/skills/ drive the pipeline:
daily-finds (capture-only sweep across sources.json), build-finds (draft
a thematic superpost), build-review (graduate a find).

sources.json carries 47 curated feeds with per-source fetch strategy
(webfetch / curl / skip) so Cloudflare-blocked sites and Reddit JSON
endpoints are handled deterministically. /sources renders a 3-column
card stream with a "Recent finds" cross-reference per source.

Seeds: 17 reviews and one superpost referencing 5 finds.
This commit is contained in:
2026-05-03 11:48:03 -04:00
commit 9858abedc2
95 changed files with 10230 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import EntryList from '../../components/EntryList.astro';
import { getAllEntries } from '../../lib/entries';
export async function getStaticPaths() {
const entries = await getAllEntries();
const categories = [...new Set(entries.map((e) => e.category))];
return categories.map((category) => ({
params: { category },
props: {
category,
entries: entries.filter((e) => e.category === category),
},
}));
}
const { category, entries } = Astro.props;
---
<BaseLayout title={`Category: ${category}`}>
<h1>Category: {category}</h1>
<EntryList entries={entries} />
<p class="back"><a href="/">← back</a></p>
<style>
h1 { margin-bottom: 1rem; }
.back { margin-top: 2rem; font-size: 0.9rem; }
</style>
</BaseLayout>
+135
View File
@@ -0,0 +1,135 @@
---
import { getCollection, render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import SourceBadge from '../../components/SourceBadge.astro';
import Backlinks from '../../components/Backlinks.astro';
import { findsReferencing } from '../../lib/backlinks';
export async function getStaticPaths() {
const items = await getCollection('find');
return items.map((item) => ({
params: { slug: item.id },
props: { item },
}));
}
const { item } = Astro.props;
const { Content } = await render(item);
const featuringPosts = await findsReferencing(item.id);
const fmt = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const linkLabel =
item.data.linkText ?? new URL(item.data.link).hostname.replace(/^www\./, '');
---
<BaseLayout
title={item.data.name}
description={item.data.description ?? item.data.subtitle ?? item.data.name}
>
<article>
<p class="source-line"><SourceBadge source={item.data.source} /></p>
<header class="find-header">
<h1>{item.data.name}</h1>
{item.data.subtitle && <p class="subtitle">{item.data.subtitle}</p>}
<p class="meta">
<time datetime={item.data.date.toISOString()}>
{fmt.format(item.data.date)}
</time>
</p>
</header>
<div class="body">
<Content />
</div>
<p class="external">
<a href={item.data.link} target="_blank" rel="noopener noreferrer">
{linkLabel} ↗
</a>
</p>
{
item.data.promotedTo && (
<p class="promoted">
→ Read the full review:
<a href={`/reviews/${item.data.promotedTo}/`}>{item.data.promotedTo}</a>
</p>
)
}
<Backlinks posts={featuringPosts} />
{
item.data.tags.length > 0 && (
<footer class="tags">
{item.data.tags.map((tag) => (
<a href={`/tags/${tag}/`} class="tag">#{tag}</a>
))}
</footer>
)
}
<p class="back"><a href="/">← back</a></p>
</article>
<style>
.source-line { margin: 0 0 0.5rem; }
.find-header {
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--rule);
}
h1 { margin: 0 0 0.35rem; }
.subtitle {
margin: 0 0 0.75rem;
color: var(--muted);
font-size: 1.1rem;
font-style: italic;
}
.meta {
margin: 0;
color: var(--muted);
font-size: 0.9rem;
}
.body :global(p) { margin: 0 0 1rem; }
.external {
margin: 1.5rem 0 0;
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
}
.external a {
display: inline-block;
padding: 0.5rem 1rem;
background: var(--accent);
color: var(--bg);
border-radius: 6px;
font-weight: 600;
font-size: 0.95rem;
}
.external a:hover { text-decoration: none; opacity: 0.9; }
.promoted {
margin: 1.5rem 0 0;
padding: 0.85rem 1rem;
background: color-mix(in srgb, var(--accent) 12%, transparent);
border-radius: 6px;
font-size: 0.95rem;
}
.promoted a { font-weight: 600; }
.tags {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--rule);
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.tag { font-size: 0.85rem; color: var(--muted); }
.tag:hover { color: var(--accent); }
.back { margin-top: 2rem; font-size: 0.9rem; }
</style>
</BaseLayout>
+95
View File
@@ -0,0 +1,95 @@
---
import { getCollection, render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import FindsGrid from '../../components/FindsGrid.astro';
import { resolveFinds } from '../../lib/find-items';
export async function getStaticPaths() {
const finds = await getCollection('finds');
return finds.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
const items = await resolveFinds(post.data.items);
const fmt = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
---
<BaseLayout
title={post.data.title}
description={post.data.description ?? post.data.blurb}
wide
>
<article>
<header class="finds-header">
<h1>{post.data.title}</h1>
<p class="meta">
<time datetime={post.data.date.toISOString()}>
{fmt.format(post.data.date)}
</time>
</p>
</header>
<div class="body">
<Content />
</div>
<FindsGrid items={items} />
{
post.data.tags.length > 0 && (
<footer class="tags">
{post.data.tags.map((tag) => (
<a href={`/tags/${tag}/`} class="tag">#{tag}</a>
))}
</footer>
)
}
<p class="back"><a href="/">← back</a></p>
</article>
<style>
.finds-header {
margin-bottom: 1.5rem;
}
h1 { margin: 0 0 0.35rem; font-size: 1.85rem; }
.meta {
margin: 0;
color: var(--muted);
font-size: 0.9rem;
}
.body {
margin-bottom: 1.75rem;
max-width: 42rem;
}
.body :global(p) { margin: 0 0 1rem; }
.body :global(blockquote) {
margin: 1.25rem 0;
padding: 0.15rem 0 0.15rem 1rem;
border-left: 3px solid var(--accent);
color: var(--fg);
font-style: normal;
}
.body :global(blockquote p) { margin: 0; }
.tags {
margin-top: 2.5rem;
padding-top: 1rem;
border-top: 1px solid var(--rule);
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.tag { font-size: 0.85rem; color: var(--muted); }
.tag:hover { color: var(--accent); }
.back { margin-top: 2rem; font-size: 0.9rem; }
</style>
</BaseLayout>
+150
View File
@@ -0,0 +1,150 @@
---
import { getCollection } from 'astro:content';
import BaseLayout from '../layouts/BaseLayout.astro';
import { heroes } from '../lib/hero';
const reviews = (await getCollection('reviews')).sort(
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
);
const tiles = reviews.map((review) => ({
href: `/reviews/${review.id}/`,
name: review.data.name,
subtitle: review.data.subtitle,
hero: heroes[review.id],
}));
---
<BaseLayout title="Grid" wide>
<div class="intro">
<h1>Grid view</h1>
<p><a href="/">← list view</a></p>
</div>
<ul class="grid">
{
tiles.map((tile) => (
<li>
<a href={tile.href} class:list={['tile', { 'text-only': !tile.hero }]}>
{tile.hero?.kind === 'image' && (
<img
class="media"
src={tile.hero.src.src}
width={tile.hero.src.width}
height={tile.hero.src.height}
loading="lazy"
decoding="async"
alt=""
/>
)}
{tile.hero?.kind === 'video' && (
<video
class="media"
src={tile.hero.src}
autoplay
loop
muted
playsinline
preload="metadata"
/>
)}
<div class="text">
<span class="name">{tile.name}</span>
<span class="subtitle">{tile.subtitle}</span>
</div>
</a>
</li>
))
}
</ul>
<style>
.intro {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
}
.intro h1 { margin: 0; font-size: 1.5rem; }
.intro p { margin: 0; font-size: 0.9rem; color: var(--muted); }
.grid {
column-count: 3;
column-gap: 1rem;
list-style: none;
margin: 0;
padding: 0;
}
@media (max-width: 880px) {
.grid { column-count: 2; }
}
@media (max-width: 520px) {
.grid { column-count: 1; }
}
.grid > li {
break-inside: avoid;
display: block;
margin: 0 0 1rem;
}
.tile {
display: block;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--rule);
background: var(--bg);
color: inherit;
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
}
.tile:hover {
text-decoration: none;
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--accent) 40%, var(--rule));
box-shadow: 0 8px 24px -10px rgba(0, 0, 0, 0.18);
}
.tile:hover .name { color: var(--accent); }
.media {
display: block;
width: 100%;
height: auto;
background: var(--rule);
}
.text {
padding: 0.85rem 1rem 1rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.name {
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
font-weight: 700;
font-size: 1rem;
letter-spacing: -0.01em;
color: var(--fg);
transition: color 0.18s ease;
}
.subtitle {
color: var(--muted);
font-size: 0.92rem;
line-height: 1.4;
}
.tile.text-only {
background: linear-gradient(155deg, var(--rule), transparent);
}
.tile.text-only .text {
padding: 1.5rem 1.25rem;
}
.tile.text-only .name {
font-size: 1.25rem;
margin-bottom: 0.3rem;
}
.tile.text-only .subtitle {
font-size: 1rem;
}
</style>
</BaseLayout>
+28
View File
@@ -0,0 +1,28 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import EntryList from '../components/EntryList.astro';
import { getAllEntries } from '../lib/entries';
const entries = (await getAllEntries()).slice(0, 12);
---
<BaseLayout title="Unique">
<EntryList entries={entries} />
<p class="view-toggle">
<a href="/sources/">sources</a>
<span aria-hidden="true">·</span>
<a href="/grid/">grid view →</a>
</p>
<style>
.view-toggle {
margin-top: 1.75rem;
text-align: right;
font-size: 0.85rem;
color: var(--muted);
}
.view-toggle a { color: var(--muted); }
.view-toggle a:hover { color: var(--accent); }
.view-toggle span { margin: 0 0.4rem; color: var(--muted); }
</style>
</BaseLayout>
+93
View File
@@ -0,0 +1,93 @@
---
import { getCollection, render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
export async function getStaticPaths() {
const posts = await getCollection('posts');
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
const fmt = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
---
<BaseLayout title={post.data.title} description={post.data.description}>
<article>
<header class="post-header">
<h1>{post.data.title}</h1>
<p class="meta">
<time datetime={post.data.date.toISOString()}>
{fmt.format(post.data.date)}
</time>
<span class="dot">·</span>
<a href={`/categories/${post.data.category}/`} class="category">
{post.data.category}
</a>
</p>
</header>
<div class="body">
<Content />
</div>
{
post.data.tags.length > 0 && (
<footer class="tags">
{post.data.tags.map((tag) => (
<a href={`/tags/${tag}/`} class="tag">#{tag}</a>
))}
</footer>
)
}
<p class="back"><a href="/">← back</a></p>
</article>
<style>
.post-header {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--rule);
}
h1 { margin: 0 0 0.5rem; }
.meta { margin: 0; color: var(--muted); font-size: 0.9rem; }
.meta .dot { margin: 0 0.4rem; }
.meta .category { color: var(--muted); }
.meta .category:hover { color: var(--accent); }
.body :global(p) { margin: 0 0 1rem; }
.body :global(h2) { font-size: 1.3rem; margin: 2rem 0 0.75rem; }
.body :global(h3) { font-size: 1.1rem; margin: 1.5rem 0 0.5rem; }
.body :global(ul), .body :global(ol) { padding-left: 1.4rem; }
.body :global(li) { margin: 0.25rem 0; }
.body :global(blockquote) {
font-size: 1.1rem;
line-height: 1.5;
font-style: normal;
border-left: 3px solid var(--accent);
padding: 0.15rem 0 0.15rem 1rem;
color: var(--fg);
margin: 0 0 1.75rem;
}
.body :global(blockquote p) { margin: 0; }
.tags {
margin-top: 2.5rem;
padding-top: 1rem;
border-top: 1px solid var(--rule);
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.tag { font-size: 0.85rem; color: var(--muted); }
.tag:hover { color: var(--accent); }
.back { margin-top: 2rem; font-size: 0.9rem; }
</style>
</BaseLayout>
+234
View File
@@ -0,0 +1,234 @@
---
import { getCollection, render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import SourceBadge from '../../components/SourceBadge.astro';
export async function getStaticPaths() {
const reviews = await getCollection('reviews');
return reviews.map((review) => ({
params: { slug: review.id },
props: { review },
}));
}
const { review } = Astro.props;
const { Content } = await render(review);
const fmt = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const linkLabel =
review.data.link &&
(review.data.linkText ?? new URL(review.data.link).hostname.replace(/^www\./, ''));
const fromFindItem = review.data.fromFind
? (await getCollection('find')).find((f) => f.id === review.data.fromFind)
: undefined;
---
<BaseLayout
title={review.data.name}
description={review.data.description ?? review.data.subtitle}
>
<article>
{review.data.source && (
<p class="source-line"><SourceBadge source={review.data.source} /></p>
)}
<header class="review-header">
<h1>{review.data.name}</h1>
<p class="subtitle">{review.data.subtitle}</p>
<p class="meta">
<time datetime={review.data.date.toISOString()}>
{fmt.format(review.data.date)}
</time>
<span class="dot">·</span>
<a href={`/categories/${review.data.category}/`} class="category">
{review.data.category}
</a>
{
review.data.link && (
<>
<span class="dot">·</span>
<a href={review.data.link} target="_blank" rel="noopener noreferrer">
{linkLabel} ↗
</a>
</>
)
}
</p>
</header>
{fromFindItem && (
<p class="from-find">
← First surfaced on{' '}
<time datetime={fromFindItem.data.date.toISOString()}>
{fmt.format(fromFindItem.data.date)}
</time>{' '}
as <a href={`/find/${fromFindItem.id}/`}>a find</a>
</p>
)}
<div class="body">
<Content />
</div>
{
review.data.tags.length > 0 && (
<footer class="tags">
{review.data.tags.map((tag) => (
<a href={`/tags/${tag}/`} class="tag">#{tag}</a>
))}
</footer>
)
}
<p class="back"><a href="/">← back</a></p>
</article>
<style>
.source-line { margin: 0 0 0.5rem; }
.from-find {
margin: 0 0 1.5rem;
padding: 0.6rem 0.85rem;
background: var(--rule);
border-radius: 6px;
font-size: 0.85rem;
color: var(--muted);
}
.from-find time { color: var(--muted); }
.review-header {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--rule);
}
h1 { margin: 0 0 0.35rem; }
.subtitle {
margin: 0 0 0.75rem;
color: var(--muted);
font-size: 1.1rem;
font-style: italic;
}
.meta {
margin: 0;
color: var(--muted);
font-size: 0.9rem;
}
.meta .dot { margin: 0 0.4rem; }
.meta .category { color: var(--muted); }
.meta .category:hover { color: var(--accent); }
.body :global(p) { margin: 0 0 1rem; }
.body :global(h2) { font-size: 1.3rem; margin: 2rem 0 0.75rem; }
.body :global(h3) { font-size: 1.1rem; margin: 1.5rem 0 0.5rem; }
.body :global(ul), .body :global(ol) { padding-left: 1.4rem; }
.body :global(li) { margin: 0.25rem 0; }
.body :global(img),
.body :global(video) {
display: block;
max-width: 100%;
height: auto;
margin: 1.5rem auto 0.5rem;
border-radius: 6px;
}
.body :global(p:has(> em:only-child)) {
text-align: center;
font-size: 0.9rem;
color: var(--muted);
margin-bottom: 1.75rem;
}
.body :global(.carousel) {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
gap: 0.75rem;
margin: 1.5rem 0 0.5rem;
padding-bottom: 0.5rem;
scrollbar-width: thin;
-webkit-overflow-scrolling: touch;
}
.body :global(.carousel > figure) {
flex: 0 0 calc(100% - 2.5rem);
scroll-snap-align: start;
margin: 0;
min-width: 0;
}
.body :global(.carousel img),
.body :global(.carousel video) {
width: 100%;
height: auto;
border-radius: 6px;
display: block;
margin: 0;
}
.body :global(.carousel figcaption) {
font-size: 0.85rem;
color: var(--muted);
text-align: center;
margin-top: 0.5rem;
font-style: italic;
}
.body :global(.carousel)::-webkit-scrollbar {
height: 8px;
}
.body :global(.carousel)::-webkit-scrollbar-track {
background: var(--rule);
border-radius: 4px;
}
.body :global(.carousel)::-webkit-scrollbar-thumb {
background: var(--muted);
border-radius: 4px;
}
.body :global(.carousel-nav) {
display: flex;
justify-content: center;
gap: 0.35rem;
margin: 0 0 1.75rem;
flex-wrap: wrap;
}
.body :global(.carousel-nav a) {
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
font-size: 0.8rem;
color: var(--muted);
background: var(--rule);
padding: 0.3rem 0.65rem;
border-radius: 999px;
text-decoration: none;
min-width: 1.6rem;
text-align: center;
line-height: 1;
transition: color 0.15s, background 0.15s;
}
.body :global(.carousel-nav a:hover) {
color: var(--bg);
background: var(--accent);
}
.body :global(.carousel:has(figure:target)) {
scroll-snap-type: none;
}
.body :global(blockquote) {
font-size: 1.1rem;
line-height: 1.5;
font-style: normal;
border-left: 3px solid var(--accent);
padding: 0.15rem 0 0.15rem 1rem;
color: var(--fg);
margin: 0 0 1.75rem;
}
.body :global(blockquote p) { margin: 0; }
.tags {
margin-top: 2.5rem;
padding-top: 1rem;
border-top: 1px solid var(--rule);
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.tag { font-size: 0.85rem; color: var(--muted); }
.tag:hover { color: var(--accent); }
.back { margin-top: 2rem; font-size: 0.9rem; }
</style>
</BaseLayout>
+226
View File
@@ -0,0 +1,226 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import sourcesData from '../../sources.json';
import { getAllFinds } from '../lib/find-items';
type Source = {
name: string;
url: string;
type: string;
topics: string[];
cadence: string;
notes: string;
};
const sources: Source[] = [...sourcesData.sources].sort((a, b) =>
a.name.localeCompare(b.name)
);
const topicLabels: Record<string, string> = {
'macos-apps': 'macOS apps',
'tools': 'tools',
'gifts': 'gifts',
'clothing': 'clothing',
'travel': 'travel',
'general-delight': 'general delight',
};
function host(url: string) {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return url;
}
}
const allFinds = await getAllFinds();
const findsBySource = new Map<string, typeof allFinds>();
for (const f of allFinds) {
const arr = findsBySource.get(f.data.source) ?? [];
arr.push(f);
findsBySource.set(f.data.source, arr);
}
---
<BaseLayout title="Sources" wide>
<div class="intro">
<h1>Sources</h1>
<p>Where the daily picks come from. {sources.length} feeds, shops, and forums.</p>
</div>
<ul class="grid">
{
sources.map((source) => {
const recent = (findsBySource.get(source.name) ?? []).slice(0, 5);
return (
<li>
<div class="card">
<a href={source.url} class="head-link" target="_blank" rel="noopener">
<div class="head">
<span class="name">{source.name}</span>
<span class="host">{host(source.url)}</span>
</div>
</a>
<p class="notes">{source.notes}</p>
<div class="meta">
<span class="type">{source.type}</span>
<span class="cadence">{source.cadence}</span>
</div>
<ul class="topics">
{source.topics.map((t) => (
<li class="topic">{topicLabels[t] ?? t}</li>
))}
</ul>
{recent.length > 0 && (
<div class="recent">
<p class="recent-label">Recent finds</p>
<ul class="recent-list">
{recent.map((f) => (
<li>
<a href={`/find/${f.id}/`}>{f.data.name}</a>
</li>
))}
</ul>
</div>
)}
</div>
</li>
);
})
}
</ul>
<p class="back"><a href="/">← back</a></p>
<style>
.intro {
margin-bottom: 1.5rem;
}
.intro h1 { margin: 0 0 0.25rem; font-size: 1.85rem; }
.intro p { margin: 0; color: var(--muted); }
.grid {
column-count: 3;
column-gap: 1rem;
list-style: none;
margin: 0;
padding: 0;
}
@media (max-width: 880px) { .grid { column-count: 2; } }
@media (max-width: 520px) { .grid { column-count: 1; } }
.grid > li {
break-inside: avoid;
display: block;
margin: 0 0 1rem;
}
.card {
display: block;
border-radius: 8px;
border: 1px solid var(--rule);
background: var(--bg);
padding: 0.95rem 1.05rem 1rem;
color: inherit;
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
}
.card:hover {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--accent) 40%, var(--rule));
box-shadow: 0 8px 24px -10px rgba(0, 0, 0, 0.18);
}
.head-link { color: inherit; display: block; }
.head-link:hover { text-decoration: none; }
.head-link:hover .name { color: var(--accent); }
.head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.6rem;
margin-bottom: 0.45rem;
}
.name {
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
font-weight: 700;
font-size: 1rem;
letter-spacing: -0.01em;
color: var(--fg);
transition: color 0.18s ease;
}
.host {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.72rem;
color: var(--muted);
flex-shrink: 0;
}
.notes {
margin: 0 0 0.7rem;
font-size: 0.92rem;
line-height: 1.4;
color: var(--fg);
}
.meta {
display: flex;
gap: 0.4rem;
margin-bottom: 0.5rem;
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
.meta .type { color: var(--accent); }
.meta .cadence::before { content: '· '; color: var(--muted); }
.topics {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.topic {
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
font-size: 0.72rem;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: var(--rule);
color: var(--muted);
}
.recent {
margin-top: 0.7rem;
padding-top: 0.6rem;
border-top: 1px dashed var(--rule);
}
.recent-label {
margin: 0 0 0.3rem;
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
.recent-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.recent-list li { font-size: 0.85rem; line-height: 1.35; }
.recent-list a { color: var(--fg); }
.recent-list a:hover { color: var(--accent); }
.back {
margin-top: 2rem;
font-size: 0.9rem;
color: var(--muted);
}
</style>
</BaseLayout>
+30
View File
@@ -0,0 +1,30 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import EntryList from '../../components/EntryList.astro';
import { getAllEntries } from '../../lib/entries';
export async function getStaticPaths() {
const entries = await getAllEntries();
const tags = [...new Set(entries.flatMap((e) => e.tags))];
return tags.map((tag) => ({
params: { tag },
props: {
tag,
entries: entries.filter((e) => e.tags.includes(tag)),
},
}));
}
const { tag, entries } = Astro.props;
---
<BaseLayout title={`Tag: ${tag}`}>
<h1>#{tag}</h1>
<EntryList entries={entries} />
<p class="back"><a href="/">← back</a></p>
<style>
h1 { margin-bottom: 1rem; }
.back { margin-top: 2rem; font-size: 0.9rem; }
</style>
</BaseLayout>