Add blog post and support pubDate/tags
CI / ci (push) Successful in 29s
CI / ci (pull_request) Successful in 28s
Docker / docker (pull_request) Successful in 17s

Add new post "After the Silence". Update content schema to use
pubDate and include tags (default empty). Update blog listing,
post page and start page to use pubDate, render tag lists, and
compute/show up to two related posts by tag overlap. Misc
formatting and small display tweaks.
This commit is contained in:
2026-03-07 12:52:05 +01:00
parent 9d929be386
commit 42bea4d9ba
7 changed files with 580 additions and 150 deletions
+202 -12
View File
@@ -1,16 +1,43 @@
---
import BaseLayout from "../../layouts/BaseLayout.astro";
import { getCollection } from "astro:content";
import { getCollection, type CollectionEntry } from "astro:content";
type BlogPost = CollectionEntry<"blog">;
export async function getStaticPaths() {
const posts = await getCollection("blog", ({ data }) => !data.draft);
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
props: {
post,
relatedPosts: posts
.filter((candidate) => candidate.slug !== post.slug)
.map((candidate) => ({
post: candidate,
score: candidate.data.tags.filter((tag) =>
post.data.tags.includes(tag),
).length,
}))
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return (
b.post.data.pubDate.valueOf() -
a.post.data.pubDate.valueOf()
);
})
.filter((candidate) => candidate.score > 0)
.slice(0, 2)
.map((candidate) => candidate.post),
},
}));
}
const { post } = Astro.props;
const { post, relatedPosts = [] } = Astro.props as {
post: BlogPost;
relatedPosts: BlogPost[];
};
const { Content } = await post.render();
function formatDate(date: Date) {
@@ -19,7 +46,7 @@ function formatDate(date: Date) {
---
<BaseLayout
title={`${post.data.title} Hidden Den Cafe`}
title={`${post.data.title} - Hidden Den Cafe`}
description={post.data.description}
>
<div class="matrix-bg" aria-hidden="true"></div>
@@ -28,14 +55,60 @@ function formatDate(date: Date) {
<div class="container">
<header class="header fade-in">
<h1 class="title">{post.data.title}</h1>
<p class="date">{formatDate(post.data.date)}</p>
<div class="divider">══════════════════════════════</div>
<p class="date">{formatDate(post.data.pubDate)}</p>
{
post.data.tags.length > 0 && (
<div
class="tag-list"
aria-label={`${post.data.title} tags`}
>
{post.data.tags.map((tag) => (
<span class="tag">{tag}</span>
))}
</div>
)
}
<div class="divider">==============================</div>
</header>
<article class="content fade-in">
<Content />
</article>
{
relatedPosts.length > 0 && (
<section
class="related fade-in"
aria-labelledby="related-posts"
>
<div class="related-head">
<h2 id="related-posts">Related Reading</h2>
<a href="/blog" class="related-link">
Back to blog
</a>
</div>
<ul class="related-list">
{relatedPosts.map((relatedPost) => (
<li class="related-item">
<a
href={`/blog/${relatedPost.slug}`}
class="related-card"
>
<span class="related-date">
{formatDate(
relatedPost.data.pubDate,
)}
</span>
<h3>{relatedPost.data.title}</h3>
<p>{relatedPost.data.description}</p>
</a>
</li>
))}
</ul>
</section>
)
}
<footer class="footer fade-in">
<p>Made with love by Latte</p>
</footer>
@@ -60,8 +133,12 @@ function formatDate(date: Date) {
}
@keyframes grid-move {
0% { transform: translate(0, 0); }
100% { transform: translate(50px, 50px); }
0% {
transform: translate(0, 0);
}
100% {
transform: translate(50px, 50px);
}
}
.main {
@@ -107,7 +184,21 @@ function formatDate(date: Date) {
font-size: 0.85rem;
}
/* Prose styles for markdown content */
.tag-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
margin-top: var(--space-sm);
}
.tag {
font-size: 0.72rem;
color: var(--color-accent);
border: 1px solid var(--color-surface);
padding: 1px 6px;
border-radius: 999px;
}
.content {
color: var(--color-text);
line-height: 1.8;
@@ -208,6 +299,82 @@ function formatDate(date: Date) {
margin-top: var(--space-xs);
}
.related {
margin-top: var(--space-xl);
padding-top: var(--space-lg);
border-top: 1px solid var(--color-surface);
}
.related-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
.related-head h2 {
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 2px;
color: var(--color-accent);
}
.related-link {
color: var(--color-blue);
font-size: 0.85rem;
white-space: nowrap;
}
.related-link:hover {
color: var(--color-accent-bright);
}
.related-list {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.related-card {
display: block;
padding: var(--space-md);
border-radius: 8px;
background: color-mix(in srgb, var(--color-bg-light) 88%, transparent);
border: 1px solid
color-mix(in srgb, var(--color-surface) 70%, transparent);
transition:
border-color 0.2s ease,
transform 0.2s ease,
box-shadow 0.2s ease;
}
.related-card:hover {
border-color: color-mix(in srgb, var(--color-accent) 55%, transparent);
transform: translateY(-2px);
box-shadow: 0 0 0 1px
color-mix(in srgb, var(--color-accent) 35%, transparent);
}
.related-date {
display: inline-block;
color: var(--color-text-dim);
font-size: 0.8rem;
margin-bottom: var(--space-xs);
}
.related-card h3 {
font-size: 1rem;
color: var(--color-accent-bright);
margin-bottom: var(--space-xs);
}
.related-card p {
color: var(--color-text-dim);
line-height: 1.7;
}
.footer {
margin-top: var(--space-xl);
text-align: center;
@@ -223,9 +390,18 @@ function formatDate(date: Date) {
opacity: 0;
}
.fade-in:nth-child(1) { animation-delay: 0.1s; }
.fade-in:nth-child(2) { animation-delay: 0.2s; }
.fade-in:nth-child(3) { animation-delay: 0.3s; }
.fade-in:nth-child(1) {
animation-delay: 0.1s;
}
.fade-in:nth-child(2) {
animation-delay: 0.2s;
}
.fade-in:nth-child(3) {
animation-delay: 0.3s;
}
.fade-in:nth-child(4) {
animation-delay: 0.4s;
}
@keyframes fadeIn {
from {
@@ -250,6 +426,11 @@ function formatDate(date: Date) {
.divider {
font-size: 0.6rem;
}
.related-head {
flex-direction: column;
align-items: flex-start;
}
}
@media (prefers-reduced-motion: reduce) {
@@ -261,5 +442,14 @@ function formatDate(date: Date) {
animation: none;
opacity: 1;
}
.related-card {
transition: none;
}
.related-card:hover {
transform: none;
box-shadow: none;
}
}
</style>
+76 -26
View File
@@ -2,8 +2,9 @@
import BaseLayout from "../../layouts/BaseLayout.astro";
import { getCollection } from "astro:content";
const posts = (await getCollection("blog", ({ data }) => !data.draft))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
const posts = (await getCollection("blog", ({ data }) => !data.draft)).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
);
function formatDate(date: Date) {
return date.toISOString().split("T")[0];
@@ -23,25 +24,50 @@ function formatDate(date: Date) {
<div class="divider">══════════════════════════════</div>
</header>
{posts.length === 0 ? (
<section class="section fade-in">
<p class="empty">No posts yet. Will get around to it.</p>
</section>
) : (
<section class="section fade-in">
<ul class="post-list">
{posts.map((post) => (
<li class="post-item">
<span class="post-date">{formatDate(post.data.date)}</span>
<div class="post-info">
<a href={`/blog/${post.slug}`} class="post-title">{post.data.title}</a>
<p class="post-desc">{post.data.description}</p>
</div>
</li>
))}
</ul>
</section>
)}
{
posts.length === 0 ? (
<section class="section fade-in">
<p class="empty">
No posts yet. Will get around to it.
</p>
</section>
) : (
<section class="section fade-in">
<ul class="post-list">
{posts.map((post) => (
<li class="post-item">
<span class="post-date">
{formatDate(post.data.pubDate)}
</span>
<div class="post-info">
<a
href={`/blog/${post.slug}`}
class="post-title"
>
{post.data.title}
</a>
<p class="post-desc">
{post.data.description}
</p>
{post.data.tags.length > 0 && (
<div
class="tag-list"
aria-label={`${post.data.title} tags`}
>
{post.data.tags.map((tag) => (
<span class="tag">
{tag}
</span>
))}
</div>
)}
</div>
</li>
))}
</ul>
</section>
)
}
<footer class="footer fade-in">
<p>Made with love by Latte</p>
@@ -67,8 +93,12 @@ function formatDate(date: Date) {
}
@keyframes grid-move {
0% { transform: translate(0, 0); }
100% { transform: translate(50px, 50px); }
0% {
transform: translate(0, 0);
}
100% {
transform: translate(50px, 50px);
}
}
.main {
@@ -156,6 +186,20 @@ function formatDate(date: Date) {
line-height: 1.6;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
}
.tag {
font-size: 0.72rem;
color: var(--color-accent);
border: 1px solid var(--color-surface);
padding: 1px 6px;
border-radius: 999px;
}
.empty {
color: var(--color-text-dim);
font-style: italic;
@@ -176,9 +220,15 @@ function formatDate(date: Date) {
opacity: 0;
}
.fade-in:nth-child(1) { animation-delay: 0.1s; }
.fade-in:nth-child(2) { animation-delay: 0.2s; }
.fade-in:nth-child(3) { animation-delay: 0.3s; }
.fade-in:nth-child(1) {
animation-delay: 0.1s;
}
.fade-in:nth-child(2) {
animation-delay: 0.2s;
}
.fade-in:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes fadeIn {
from {
+142 -88
View File
@@ -16,7 +16,7 @@ type Project = {
};
const posts = (await getCollection("blog", ({ data }) => !data.draft)).sort(
(a, b) => b.data.date.valueOf() - a.data.date.valueOf(),
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
);
const recommendedPosts = posts.slice(0, 3);
@@ -28,12 +28,17 @@ const highlightedProjects = highlightedProjectNames
.map((name) => typedProjects.find((project) => project.name === name))
.filter((project): project is Project => Boolean(project));
function formatDate(date: CollectionEntry<"blog">["data"]["date"]) {
function formatDate(date: CollectionEntry<"blog">["data"]["pubDate"]) {
return date.toISOString().split("T")[0];
}
function getProjectLink(project: Project) {
return project.links.site || project.links.gitea || project.links.github || "/projects";
return (
project.links.site ||
project.links.gitea ||
project.links.github ||
"/projects"
);
}
function getProjectLinkLabel(project: Project) {
@@ -57,75 +62,95 @@ function getProjectLinkLabel(project: Project) {
<h1 class="title">Start Here</h1>
<div class="divider">==============================</div>
<p class="lead">
This page is a small orientation point for first-time visitors. If
you have just found Hidden Den, this is the quickest way to get a
feel for what kind of place it is and where you might want to wander
next.
This page is a small orientation point for first-time
visitors. If you have just found Hidden Den, this is the
quickest way to get a feel for what kind of place it is and
where you might want to wander next.
</p>
</header>
<section class="section fade-in" aria-labelledby="what-this-place-is">
<section
class="section fade-in"
aria-labelledby="what-this-place-is"
>
<h2 id="what-this-place-is">What Hidden Den Is</h2>
<p class="desc">
Hidden Den is Latte&apos;s personal corner of the internet: part
writing space, part workshop, part quiet place to think out loud. It
holds projects, experiments, infrastructure-minded notes, and the
kind of personal web presence that does not need to behave like a
brand.
Hidden Den is Latte&apos;s personal corner of the internet:
part writing space, part workshop, part quiet place to think
out loud. It holds projects, experiments,
infrastructure-minded notes, and the kind of personal web
presence that does not need to behave like a brand.
</p>
<p class="desc">
The site leans toward privacy, ownership, and human-scale spaces
online. It is built to feel warm and readable rather than optimized,
noisy, or extractive. More cozy tech wizard than cyberpunk hacker.
The site leans toward privacy, ownership, and human-scale
spaces online. It is built to feel warm and readable rather
than optimized, noisy, or extractive. More cozy tech wizard
than cyberpunk hacker.
</p>
<div class="note">
<p>
If you want the short version: this is a personal site for
writing, building, self-hosting, and keeping a small piece of the
web genuinely personal.
If you want the short version: this is a personal site
for writing, building, self-hosting, and keeping a small
piece of the web genuinely personal.
</p>
</div>
</section>
<section class="section fade-in" aria-labelledby="recommended-reading">
<section
class="section fade-in"
aria-labelledby="recommended-reading"
>
<div class="section-head">
<h2 id="recommended-reading">Recommended Reading</h2>
<a class="section-link" href="/blog">See all posts</a>
</div>
<p class="desc section-intro">
A few good starting points from the blog. These give a feel for the
den so far without asking you to dig through everything first.
A few good starting points from the blog. These give a feel
for the den so far without asking you to dig through
everything first.
</p>
{recommendedPosts.length === 0 ? (
<p class="empty">The den is still quiet on the writing front.</p>
) : (
<ul class="reading-list">
{recommendedPosts.map((post) => (
<li class="reading-item">
<article class="content-card">
<div class="meta-row">
<span class="meta-label">posted</span>
<time datetime={formatDate(post.data.date)}>
{formatDate(post.data.date)}
</time>
</div>
<h3>
<a href={`/blog/${post.slug}`}>{post.data.title}</a>
</h3>
<p>{post.data.description}</p>
</article>
</li>
))}
</ul>
)}
{
recommendedPosts.length === 0 ? (
<p class="empty">
The den is still quiet on the writing front.
</p>
) : (
<ul class="reading-list">
{recommendedPosts.map((post) => (
<li class="reading-item">
<article class="content-card">
<div class="meta-row">
<span class="meta-label">
posted
</span>
<time
datetime={formatDate(
post.data.pubDate,
)}
>
{formatDate(post.data.pubDate)}
</time>
</div>
<h3>
<a href={`/blog/${post.slug}`}>
{post.data.title}
</a>
</h3>
<p>{post.data.description}</p>
</article>
</li>
))}
</ul>
)
}
</section>
<section class="section fade-in" aria-labelledby="explore-the-site">
<h2 id="explore-the-site">Explore The Site</h2>
<p class="desc section-intro">
If you would rather browse by section, these are the best places to
continue.
If you would rather browse by section, these are the best
places to continue.
</p>
<div class="explore-grid">
@@ -133,9 +158,9 @@ function getProjectLinkLabel(project: Project) {
<span class="card-kicker">About</span>
<h3>Meet the person behind the den</h3>
<p>
A fuller introduction to Latte, the philosophy behind the
site, and the kind of internet this place is trying to make
room for.
A fuller introduction to Latte, the philosophy
behind the site, and the kind of internet this place
is trying to make room for.
</p>
</a>
@@ -143,8 +168,9 @@ function getProjectLinkLabel(project: Project) {
<span class="card-kicker">Blog</span>
<h3>Read the writing</h3>
<p>
Thoughts, personal pieces, and technical reflections gathered
in one place without feeds, tracking, or platform noise.
Thoughts, personal pieces, and technical reflections
gathered in one place without feeds, tracking, or
platform noise.
</p>
</a>
@@ -152,52 +178,76 @@ function getProjectLinkLabel(project: Project) {
<span class="card-kicker">Projects</span>
<h3>See what is being built</h3>
<p>
Bots, tools, experiments, and den-adjacent systems that show
the practical side of the site.
Bots, tools, experiments, and den-adjacent systems
that show the practical side of the site.
</p>
</a>
</div>
</section>
<section class="section fade-in" aria-labelledby="project-highlights">
<section
class="section fade-in"
aria-labelledby="project-highlights"
>
<div class="section-head">
<h2 id="project-highlights">Projects Worth Seeing</h2>
<a class="section-link" href="/projects">Browse all projects</a>
<a class="section-link" href="/projects"
>Browse all projects</a
>
</div>
<p class="desc section-intro">
A small curated subset, just enough to sketch the shape of the work
without turning this page into a full catalog.
A small curated subset, just enough to sketch the shape of
the work without turning this page into a full catalog.
</p>
<ul class="project-list">
{highlightedProjects.map((project) => (
<li class="project-item">
<article class="content-card project-card">
<div class="project-topline">
<h3>{project.name}</h3>
<span class={`status status-${project.status}`}>
{project.status}
</span>
</div>
<p>{project.description}</p>
<div class="project-footer">
<div class="tag-list" aria-label={`${project.name} tags`}>
{project.tags.map((tag) => (
<span class="tag">{tag}</span>
))}
{
highlightedProjects.map((project) => (
<li class="project-item">
<article class="content-card project-card">
<div class="project-topline">
<h3>{project.name}</h3>
<span
class={`status status-${project.status}`}
>
{project.status}
</span>
</div>
<a
class="project-link"
href={getProjectLink(project)}
target={getProjectLink(project).startsWith("http") ? "_blank" : undefined}
rel={getProjectLink(project).startsWith("http") ? "noopener noreferrer" : undefined}
>
{getProjectLinkLabel(project)}
</a>
</div>
</article>
</li>
))}
<p>{project.description}</p>
<div class="project-footer">
<div
class="tag-list"
aria-label={`${project.name} tags`}
>
{project.tags.map((tag) => (
<span class="tag">{tag}</span>
))}
</div>
<a
class="project-link"
href={getProjectLink(project)}
target={
getProjectLink(
project,
).startsWith("http")
? "_blank"
: undefined
}
rel={
getProjectLink(
project,
).startsWith("http")
? "noopener noreferrer"
: undefined
}
>
{getProjectLinkLabel(project)}
</a>
</div>
</article>
</li>
))
}
</ul>
</section>
@@ -337,7 +387,8 @@ function getProjectLinkLabel(project: Project) {
.note {
margin-top: var(--space-md);
padding: var(--space-md);
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
border: 1px solid
color-mix(in srgb, var(--color-accent) 30%, transparent);
background:
linear-gradient(
135deg,
@@ -365,7 +416,8 @@ function getProjectLinkLabel(project: Project) {
padding: var(--space-md);
border-radius: 8px;
background: color-mix(in srgb, var(--color-bg-light) 88%, transparent);
border: 1px solid color-mix(in srgb, var(--color-surface) 70%, transparent);
border: 1px solid
color-mix(in srgb, var(--color-surface) 70%, transparent);
}
.content-card h3 {
@@ -405,7 +457,8 @@ function getProjectLinkLabel(project: Project) {
display: block;
padding: var(--space-md);
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--color-surface) 70%, transparent);
border: 1px solid
color-mix(in srgb, var(--color-surface) 70%, transparent);
background: color-mix(in srgb, var(--color-bg-light) 88%, transparent);
transition:
border-color 0.2s ease,
@@ -416,7 +469,8 @@ function getProjectLinkLabel(project: Project) {
.explore-card:hover {
border-color: color-mix(in srgb, var(--color-accent) 55%, transparent);
transform: translateY(-2px);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-accent) 35%, transparent);
box-shadow: 0 0 0 1px
color-mix(in srgb, var(--color-accent) 35%, transparent);
}
.card-kicker {