dev #60

Merged
Latte merged 42 commits from dev into main 2026-03-08 09:41:15 +00:00
5 changed files with 554 additions and 23 deletions
Showing only changes of commit 59211909ac - Show all commits
+17
View File
@@ -0,0 +1,17 @@
export function formatBlogDate(date: Date) {
return date.toISOString().split("T")[0];
}
export function slugifyTag(tag: string) {
return tag
.toLowerCase()
.trim()
.replace(/&/g, " and ")
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-");
}
export function getTagHref(tag: string) {
return `/blog/tag/${slugifyTag(tag)}`;
}
+19
View File
@@ -0,0 +1,19 @@
export function getReadingTime(content: string) {
const normalized = content
.replace(/```[\s\S]*?```/g, " ")
.replace(/`[^`]*`/g, " ")
.replace(/!\[[^\]]*\]\([^)]+\)/g, " ")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/<[^>]+>/g, " ")
.replace(/[#>*_~-]/g, " ")
.replace(/\s+/g, " ")
.trim();
const wordCount = normalized ? normalized.split(" ").length : 0;
const minutes = Math.max(1, Math.ceil(wordCount / 200));
return {
minutes,
text: `${minutes} min read`,
};
}
+78 -12
View File
@@ -1,6 +1,8 @@
--- ---
import BaseLayout from "../../layouts/BaseLayout.astro"; import BaseLayout from "../../layouts/BaseLayout.astro";
import { getCollection, type CollectionEntry } from "astro:content"; import { getCollection, type CollectionEntry } from "astro:content";
import { formatBlogDate, getTagHref } from "../../lib/blog";
import { getReadingTime } from "../../lib/readingTime";
type BlogPost = CollectionEntry<"blog">; type BlogPost = CollectionEntry<"blog">;
@@ -69,10 +71,7 @@ const {
}; };
const { Content } = await post.render(); const { Content } = await post.render();
const readingTime = getReadingTime(post.body);
function formatDate(date: Date) {
return date.toISOString().split("T")[0];
}
--- ---
<BaseLayout <BaseLayout
@@ -92,7 +91,16 @@ function formatDate(date: Date) {
) )
} }
<h1 class="title">{post.data.title}</h1> <h1 class="title">{post.data.title}</h1>
<p class="date">{formatDate(post.data.pubDate)}</p> <div class="meta-row">
<time
class="date"
datetime={formatBlogDate(post.data.pubDate)}
>
{formatBlogDate(post.data.pubDate)}
</time>
<span class="meta-sep" aria-hidden="true">·</span>
<span class="reading-time">{readingTime.text}</span>
</div>
{ {
post.data.tags.length > 0 && ( post.data.tags.length > 0 && (
<div <div
@@ -100,7 +108,9 @@ function formatDate(date: Date) {
aria-label={`${post.data.title} tags`} aria-label={`${post.data.title} tags`}
> >
{post.data.tags.map((tag) => ( {post.data.tags.map((tag) => (
<span class="tag">{tag}</span> <a class="tag" href={getTagHref(tag)}>
{tag}
</a>
))} ))}
</div> </div>
) )
@@ -185,11 +195,26 @@ function formatDate(date: Date) {
href={`/blog/${relatedPost.slug}`} href={`/blog/${relatedPost.slug}`}
class="related-card" class="related-card"
> >
<span class="related-date"> <div class="related-meta">
{formatDate( <span class="related-date">
relatedPost.data.pubDate, {formatBlogDate(
)} relatedPost.data.pubDate,
</span> )}
</span>
<span
class="related-sep"
aria-hidden="true"
>
·
</span>
<span class="related-reading-time">
{
getReadingTime(
relatedPost.body,
).text
}
</span>
</div>
<h3>{relatedPost.data.title}</h3> <h3>{relatedPost.data.title}</h3>
<p>{relatedPost.data.description}</p> <p>{relatedPost.data.description}</p>
</a> </a>
@@ -283,6 +308,22 @@ function formatDate(date: Date) {
font-size: 0.85rem; font-size: 0.85rem;
} }
.meta-row {
display: flex;
align-items: center;
gap: var(--space-xs);
color: var(--color-text-dim);
font-size: 0.85rem;
}
.meta-sep {
color: var(--color-surface);
}
.reading-time {
color: var(--color-accent);
}
.tag-list { .tag-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -296,6 +337,12 @@ function formatDate(date: Date) {
border: 1px solid var(--color-surface); border: 1px solid var(--color-surface);
padding: 1px 6px; padding: 1px 6px;
border-radius: 999px; border-radius: 999px;
text-decoration: none;
}
.tag:hover {
color: var(--color-accent-bright);
border-color: color-mix(in srgb, var(--color-accent) 45%, transparent);
} }
.content { .content {
@@ -547,12 +594,26 @@ function formatDate(date: Date) {
} }
.related-date { .related-date {
display: inline-block;
color: var(--color-text-dim); color: var(--color-text-dim);
font-size: 0.8rem; font-size: 0.8rem;
}
.related-meta {
display: flex;
align-items: center;
gap: var(--space-xs);
margin-bottom: var(--space-xs); margin-bottom: var(--space-xs);
} }
.related-sep {
color: var(--color-surface);
}
.related-reading-time {
color: var(--color-accent);
font-size: 0.78rem;
}
.related-card h3 { .related-card h3 {
font-size: 1rem; font-size: 1rem;
color: var(--color-accent-bright); color: var(--color-accent-bright);
@@ -625,6 +686,11 @@ function formatDate(date: Date) {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
} }
.meta-row,
.related-meta {
flex-wrap: wrap;
}
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
+45 -11
View File
@@ -1,14 +1,17 @@
--- ---
import BaseLayout from "../../layouts/BaseLayout.astro"; import BaseLayout from "../../layouts/BaseLayout.astro";
import { getCollection } from "astro:content"; import { getCollection } from "astro:content";
import { formatBlogDate, getTagHref } from "../../lib/blog";
import { getReadingTime } from "../../lib/readingTime";
const posts = (await getCollection("blog", ({ data }) => !data.draft)).sort( const posts = (await getCollection("blog", ({ data }) => !data.draft)).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(), (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
); );
function formatDate(date: Date) { const previewPosts = posts.map((post) => ({
return date.toISOString().split("T")[0]; ...post,
} readingTime: getReadingTime(post.body),
}));
--- ---
<BaseLayout <BaseLayout
@@ -34,11 +37,21 @@ function formatDate(date: Date) {
) : ( ) : (
<section class="section fade-in"> <section class="section fade-in">
<ul class="post-list"> <ul class="post-list">
{posts.map((post) => ( {previewPosts.map((post) => (
<li class="post-item"> <li class="post-item">
<span class="post-date"> <div class="post-meta-column">
{formatDate(post.data.pubDate)} <time
</span> class="post-date"
datetime={formatBlogDate(
post.data.pubDate,
)}
>
{formatBlogDate(post.data.pubDate)}
</time>
<span class="post-reading-time">
{post.readingTime.text}
</span>
</div>
<div class="post-info"> <div class="post-info">
<a <a
href={`/blog/${post.slug}`} href={`/blog/${post.slug}`}
@@ -55,9 +68,12 @@ function formatDate(date: Date) {
aria-label={`${post.data.title} tags`} aria-label={`${post.data.title} tags`}
> >
{post.data.tags.map((tag) => ( {post.data.tags.map((tag) => (
<span class="tag"> <a
class="tag"
href={getTagHref(tag)}
>
{tag} {tag}
</span> </a>
))} ))}
</div> </div>
)} )}
@@ -156,12 +172,24 @@ function formatDate(date: Date) {
align-items: flex-start; align-items: flex-start;
} }
.post-meta-column {
min-width: 110px;
display: flex;
flex-direction: column;
gap: 2px;
}
.post-date { .post-date {
color: var(--color-text-dim); color: var(--color-text-dim);
font-size: 0.8rem; font-size: 0.8rem;
white-space: nowrap; white-space: nowrap;
padding-top: 2px; padding-top: 2px;
min-width: 90px; }
.post-reading-time {
color: var(--color-accent);
font-size: 0.72rem;
white-space: nowrap;
} }
.post-info { .post-info {
@@ -198,6 +226,12 @@ function formatDate(date: Date) {
border: 1px solid var(--color-surface); border: 1px solid var(--color-surface);
padding: 1px 6px; padding: 1px 6px;
border-radius: 999px; border-radius: 999px;
text-decoration: none;
}
.tag:hover {
color: var(--color-accent-bright);
border-color: color-mix(in srgb, var(--color-accent) 45%, transparent);
} }
.empty { .empty {
@@ -259,7 +293,7 @@ function formatDate(date: Date) {
gap: var(--space-xs); gap: var(--space-xs);
} }
.post-date { .post-meta-column {
min-width: unset; min-width: unset;
} }
} }
+395
View File
@@ -0,0 +1,395 @@
---
import BaseLayout from "../../../layouts/BaseLayout.astro";
import { getCollection, type CollectionEntry } from "astro:content";
import { formatBlogDate, getTagHref, slugifyTag } from "../../../lib/blog";
import { getReadingTime } from "../../../lib/readingTime";
type BlogPost = CollectionEntry<"blog">;
export async function getStaticPaths() {
const posts = await getCollection("blog", ({ data }) => !data.draft);
const tagMap = new Map<string, { tag: string; posts: BlogPost[] }>();
for (const post of posts) {
for (const tag of post.data.tags) {
const slug = slugifyTag(tag);
const existing = tagMap.get(slug);
if (existing) {
existing.posts.push(post);
} else {
tagMap.set(slug, { tag, posts: [post] });
}
}
}
return [...tagMap.entries()].map(([slug, entry]) => ({
params: { tag: slug },
props: {
tag: entry.tag,
posts: entry.posts.sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
),
},
}));
}
const { tag, posts } = Astro.props as {
tag: string;
posts: BlogPost[];
};
const previewPosts = posts.map((post) => ({
...post,
readingTime: getReadingTime(post.body),
}));
---
<BaseLayout
title={`Tag: ${tag} - Hidden Den Cafe`}
description={`Blog posts tagged ${tag} on Hidden Den.`}
>
<div class="matrix-bg" aria-hidden="true"></div>
<main class="main">
<div class="container">
<header class="header fade-in">
<p class="eyebrow">Blog tag</p>
<h1 class="title">{tag}</h1>
<div class="divider">==============================</div>
<p class="lead">
Posts filed under <span class="highlight">{tag}</span>. A
small cluster of related thoughts from the den.
</p>
</header>
<section class="section fade-in" aria-labelledby="tag-posts">
<div class="section-head">
<h2 id="tag-posts">Posts</h2>
<a class="section-link" href="/blog">Back to blog</a>
</div>
<ul class="post-list">
{previewPosts.map((post) => (
<li class="post-item">
<div class="post-meta-column">
<time
class="post-date"
datetime={formatBlogDate(post.data.pubDate)}
>
{formatBlogDate(post.data.pubDate)}
</time>
<span class="post-reading-time">
{post.readingTime.text}
</span>
</div>
<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((entryTag) => (
<a
class:list={[
"tag",
{
active:
entryTag === tag,
},
]}
href={getTagHref(entryTag)}
>
{entryTag}
</a>
))}
</div>
)}
</div>
</li>
))}
</ul>
</section>
<footer class="footer fade-in">
<p>Made with love by Latte</p>
</footer>
</div>
</main>
</BaseLayout>
<style>
.matrix-bg {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
opacity: 0.03;
background:
linear-gradient(var(--color-accent) 1px, transparent 1px),
linear-gradient(90deg, var(--color-accent) 1px, transparent 1px);
background-size: 50px 50px;
animation: grid-move 20s linear infinite;
}
@keyframes grid-move {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(50px, 50px);
}
}
.main {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-lg) var(--space-md);
padding-top: calc(var(--space-lg) + 3rem);
}
.container {
max-width: 700px;
width: 100%;
background: var(--color-glass);
backdrop-filter: blur(10px);
border: 1px solid var(--color-surface);
border-radius: 8px;
padding: var(--space-xl);
}
.header {
text-align: center;
margin-bottom: var(--space-lg);
}
.eyebrow {
color: var(--color-text-dim);
text-transform: uppercase;
letter-spacing: 0.24em;
font-size: 0.75rem;
margin-bottom: var(--space-sm);
}
.title {
font-size: 2rem;
font-weight: 700;
letter-spacing: 2px;
}
.divider {
color: var(--color-surface);
text-align: center;
font-size: 0.75rem;
margin: var(--space-md) 0;
user-select: none;
}
.lead {
color: var(--color-text-dim);
line-height: 1.8;
max-width: 38rem;
margin: 0 auto;
}
.highlight {
color: var(--color-accent-bright);
font-weight: 700;
}
.section {
margin: var(--space-md) 0;
}
.section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
.section-head h2 {
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 2px;
color: var(--color-accent);
}
.section-link {
color: var(--color-blue);
font-size: 0.85rem;
white-space: nowrap;
}
.section-link:hover {
color: var(--color-accent-bright);
}
.post-list {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.post-item {
display: flex;
gap: var(--space-md);
align-items: flex-start;
}
.post-meta-column {
min-width: 110px;
display: flex;
flex-direction: column;
gap: 2px;
}
.post-date {
color: var(--color-text-dim);
font-size: 0.8rem;
white-space: nowrap;
padding-top: 2px;
}
.post-reading-time {
color: var(--color-accent);
font-size: 0.72rem;
white-space: nowrap;
}
.post-info {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.post-title {
color: var(--color-blue);
font-size: 1rem;
font-weight: 700;
}
.post-title:hover {
color: var(--color-accent-bright);
}
.post-desc {
color: var(--color-text-dim);
font-size: 0.85rem;
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;
text-decoration: none;
}
.tag:hover,
.tag.active {
color: var(--color-accent-bright);
border-color: color-mix(in srgb, var(--color-accent) 45%, transparent);
}
.footer {
margin-top: var(--space-xl);
text-align: center;
}
.footer p {
color: var(--color-text-dim);
font-size: 0.85rem;
}
.fade-in {
animation: fadeIn 0.6s ease-out forwards;
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;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 600px) {
.container {
padding: var(--space-md);
}
.title {
font-size: 1.5rem;
}
.divider {
font-size: 0.6rem;
}
.lead {
font-size: 0.95rem;
}
.section-head {
flex-direction: column;
align-items: flex-start;
}
.post-item {
flex-direction: column;
gap: var(--space-xs);
}
.post-meta-column {
min-width: unset;
}
}
@media (prefers-reduced-motion: reduce) {
.matrix-bg {
animation: none;
}
.fade-in {
animation: none;
opacity: 1;
}
}
</style>