mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-11 00:05:01 +00:00
feat: add the blog post page
Article shell with a sticky meta bar carrying reading progress, a right rail (chapter scroll-spy built from server-rendered heading ids, post facts, neighbors), prev/next cards and BlogPosting JSON-LD with real publish dates. MDX posts can embed the Callout component.
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
---
|
||||
/**
|
||||
* Inline callout for MDX blog posts.
|
||||
*
|
||||
* import Callout from '../../components/blog/Callout.astro';
|
||||
* <Callout title="Why this matters">...</Callout>
|
||||
*
|
||||
* Variants: 'note' (sky), 'warn' (amber).
|
||||
*/
|
||||
interface Props {
|
||||
title?: string;
|
||||
variant?: 'note' | 'warn';
|
||||
}
|
||||
const { title, variant = 'note' } = Astro.props;
|
||||
|
||||
const tone =
|
||||
variant === 'warn'
|
||||
? { ring: 'ring-amber-200', bg: 'bg-amber-50/60', label: 'text-amber-700', bar: 'bg-amber-400' }
|
||||
: { ring: 'ring-sky-200', bg: 'bg-sky-50/60', label: 'text-sky-700', bar: 'bg-sky-400' };
|
||||
---
|
||||
<aside class={`relative my-8 rounded-[12px] ring-1 ${tone.ring} ${tone.bg} overflow-hidden`}>
|
||||
<span class={`absolute left-0 top-0 bottom-0 w-1 ${tone.bar}`}></span>
|
||||
<div class="pl-6 pr-5 py-4">
|
||||
{title && (
|
||||
<div class={`text-[10.5px] uppercase tracking-[0.18em] font-mono font-semibold ${tone.label} mb-1.5`}>{title}</div>
|
||||
)}
|
||||
<div class="callout-body text-[14px] leading-relaxed text-foreground/80">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
/* Markdown wraps slot text in <p>; keep callout copy compact. */
|
||||
.callout-body :global(p) {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.callout-body :global(p:first-child) { margin-top: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,255 @@
|
||||
---
|
||||
import type { MarkdownHeading } from 'astro';
|
||||
import Layout from './Layout.astro';
|
||||
import Icon from '../components/Icon.astro';
|
||||
import CTA from '../components/CTA.astro';
|
||||
import { formatDate, isoDate, readingTimeOf, type BlogPost } from '../lib/blog';
|
||||
|
||||
interface Props {
|
||||
post: BlogPost;
|
||||
prev?: BlogPost;
|
||||
next?: BlogPost;
|
||||
headings?: MarkdownHeading[];
|
||||
}
|
||||
const { post, prev, next, headings = [] } = Astro.props;
|
||||
const { title, description, pubDate, updatedDate, author, tags } = post.data;
|
||||
const reading = readingTimeOf(post);
|
||||
const chapters = headings.filter((h) => h.depth === 2);
|
||||
|
||||
const siteOrigin = (Astro.site ?? new URL('https://warmbly.com')).toString().replace(/\/$/, '');
|
||||
const canonical = new URL(Astro.url.pathname, Astro.site ?? 'https://warmbly.com').toString();
|
||||
|
||||
const articleJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: title,
|
||||
description,
|
||||
image: `${siteOrigin}/og-image.png`,
|
||||
datePublished: pubDate.toISOString(),
|
||||
dateModified: (updatedDate ?? pubDate).toISOString(),
|
||||
author:
|
||||
author === 'Warmbly team'
|
||||
? { '@id': `${siteOrigin}#org` }
|
||||
: { '@type': 'Person', name: author },
|
||||
publisher: { '@id': `${siteOrigin}#org` },
|
||||
mainEntityOfPage: { '@type': 'WebPage', '@id': canonical },
|
||||
isAccessibleForFree: true,
|
||||
};
|
||||
|
||||
const neighbors = [
|
||||
prev ? { label: 'Previously', post: prev } : null,
|
||||
next ? { label: 'Up next', post: next } : null,
|
||||
].filter(Boolean) as { label: string; post: BlogPost }[];
|
||||
---
|
||||
<Layout title={`${title} | Warmbly Blog`} description={description} ogType="article" jsonLd={articleJsonLd}>
|
||||
<!-- TOP BAR: sticky under the site header, carries the reading progress -->
|
||||
<div class="sticky top-14 z-30 bg-white/90 backdrop-blur-md border-b border-[color:var(--border)]">
|
||||
<div class="container-page py-3 flex items-center justify-between text-[11.5px] font-mono">
|
||||
<a href="/blog/" class="inline-flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Icon name="arrowRight" size={11} class="rotate-180" />
|
||||
Blog
|
||||
</a>
|
||||
<div class="flex items-center gap-4 text-muted-foreground">
|
||||
<span class="hidden sm:inline">{isoDate(pubDate)}</span>
|
||||
<span class="hidden sm:inline w-1 h-1 rounded-full bg-[color:var(--border)]"></span>
|
||||
<span>{reading}</span>
|
||||
{updatedDate && <span class="hidden md:inline w-1 h-1 rounded-full bg-[color:var(--border)]"></span>}
|
||||
{updatedDate && <span class="hidden md:inline">Updated {isoDate(updatedDate)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div class="reading-progress" data-reading-progress></div>
|
||||
</div>
|
||||
|
||||
<article class="bg-white">
|
||||
<!-- HEADER -->
|
||||
<header class="border-b border-[color:var(--border)]">
|
||||
<div class="container-page py-12 md:py-16">
|
||||
<div class="max-w-[820px]">
|
||||
<div class="flex items-center gap-3 flex-wrap mb-4">
|
||||
<span class="text-[10.5px] uppercase tracking-[0.22em] font-mono text-[#0284c7]">Blog</span>
|
||||
{tags.map((tag) => (
|
||||
<a href={`/blog/tag/${tag}/`} class="inline-flex items-center h-5 px-2 rounded-full text-[10.5px] font-mono uppercase tracking-[0.1em] bg-sky-50 text-sky-700 hover:bg-sky-100 transition-colors">{tag}</a>
|
||||
))}
|
||||
</div>
|
||||
<h1 class="text-[36px] md:text-[52px] font-semibold tracking-[-0.03em] leading-[1.05] text-heading">{title}</h1>
|
||||
<p class="mt-4 text-[16px] md:text-[17px] text-foreground/70 max-w-2xl leading-relaxed">{description}</p>
|
||||
<div class="mt-6 flex items-center gap-3 text-[12.5px] text-muted-foreground">
|
||||
<img src="/brand/social-avatar.jpg" alt="" width="24" height="24" class="w-6 h-6 rounded-full ring-1 ring-[color:var(--border)]" loading="lazy" />
|
||||
<span class="font-medium text-foreground/80">{author}</span>
|
||||
<span class="w-1 h-1 rounded-full bg-[color:var(--border)]"></span>
|
||||
<time datetime={pubDate.toISOString()}>{formatDate(pubDate)}</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- BODY: prose column + sticky right rail -->
|
||||
<div class="container-page py-12 md:py-16">
|
||||
<div class="grid lg:grid-cols-[minmax(0,1fr)_240px] xl:grid-cols-[minmax(0,1fr)_260px] gap-12 xl:gap-16 items-start">
|
||||
<div class="article-prose max-w-[720px] min-w-0" data-article-body>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- RIGHT RAIL -->
|
||||
<aside class="hidden lg:flex flex-col gap-7 sticky top-[6.25rem]">
|
||||
{chapters.length > 0 && (
|
||||
<div>
|
||||
<div class="text-[10.5px] uppercase tracking-[0.22em] font-mono text-muted-foreground mb-4">On this page</div>
|
||||
<ol class="space-y-px text-[13px]" data-chapter-rail>
|
||||
{chapters.map((h, i) => (
|
||||
<li>
|
||||
<a
|
||||
href={`#${h.slug}`}
|
||||
data-chapter-link={h.slug}
|
||||
class="group flex items-baseline gap-3 py-1.5 px-2 -mx-2 rounded-md transition-colors duration-300 ease-out text-foreground/60 hover:text-[#0369a1]"
|
||||
>
|
||||
<span class="font-mono text-[10.5px] w-5 shrink-0 text-muted-foreground/55">{String(i + 1).padStart(2, '0')}</span>
|
||||
<span class="leading-snug line-clamp-2">{h.text}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class={chapters.length > 0 ? 'pt-6 border-t border-[color:var(--border)]' : ''}>
|
||||
<div class="text-[10.5px] uppercase tracking-[0.22em] font-mono text-muted-foreground mb-4">About this post</div>
|
||||
<dl class="space-y-3 text-[12.5px]">
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<dt class="font-mono text-[11px] text-muted-foreground">Published</dt>
|
||||
<dd class="text-foreground/75">{formatDate(pubDate)}</dd>
|
||||
</div>
|
||||
{updatedDate && (
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<dt class="font-mono text-[11px] text-muted-foreground">Updated</dt>
|
||||
<dd class="text-foreground/75">{formatDate(updatedDate)}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<dt class="font-mono text-[11px] text-muted-foreground">Length</dt>
|
||||
<dd class="text-foreground/75">{reading}</dd>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<dt class="font-mono text-[11px] text-muted-foreground">By</dt>
|
||||
<dd class="text-foreground/75">{author}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{tags.length > 0 && (
|
||||
<div class="mt-4 flex items-center gap-2 flex-wrap">
|
||||
{tags.map((tag) => (
|
||||
<a href={`/blog/tag/${tag}/`} class="inline-flex items-center h-5 px-2 rounded-full text-[10.5px] font-mono uppercase tracking-[0.1em] bg-sky-50 text-sky-700 hover:bg-sky-100 transition-colors">{tag}</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="pt-6 border-t border-[color:var(--border)]">
|
||||
<div class="text-[10.5px] uppercase tracking-[0.22em] font-mono text-muted-foreground mb-4">Keep reading</div>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
{neighbors.map(({ post: n }) => (
|
||||
<a href={`/blog/${n.id}/`} class="group text-[13px] leading-snug text-foreground/70 hover:text-[#0369a1] transition-colors">
|
||||
{n.data.title}
|
||||
</a>
|
||||
))}
|
||||
<a href="/blog/" class="text-[13px] leading-snug text-foreground/70 hover:text-[#0369a1] transition-colors">All posts</a>
|
||||
</div>
|
||||
<a href="/blog/rss.xml" class="mt-5 inline-flex items-center gap-2 text-[11.5px] font-mono text-[#0369a1] hover:text-[#075985]">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 11a9 9 0 0 1 9 9" /><path d="M4 4a16 16 0 0 1 16 16" /><circle cx="5" cy="19" r="1" />
|
||||
</svg>
|
||||
Subscribe via RSS
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PREVIOUS / NEXT -->
|
||||
{neighbors.length > 0 && (
|
||||
<footer class="border-t border-[color:var(--border)]">
|
||||
<div class="container-page py-12">
|
||||
<div class="text-[10.5px] uppercase tracking-[0.22em] font-mono text-muted-foreground mb-5">More from the blog</div>
|
||||
<div class="grid sm:grid-cols-2 gap-3">
|
||||
{neighbors.map(({ label, post: n }) => (
|
||||
<a href={`/blog/${n.id}/`} class="group flex items-center justify-between p-5 rounded-[12px] bg-[color:var(--surface-2)] ring-1 ring-[color:var(--border)] hover:ring-[#0284c7]/40 hover:bg-white transition-all">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[10.5px] font-mono text-muted-foreground">{label} · {isoDate(n.data.pubDate)}</div>
|
||||
<div class="mt-1 text-[15px] font-semibold text-heading group-hover:text-[#0369a1] transition-colors leading-snug">{n.data.title}</div>
|
||||
</div>
|
||||
<Icon name="arrowRight" size={14} class="shrink-0 ml-4 text-muted-foreground group-hover:text-[#0369a1] group-hover:translate-x-0.5 transition-all" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<CTA />
|
||||
|
||||
<script is:inline>
|
||||
// Reading progress, hover anchors on H2s, and rail scroll-spy.
|
||||
// Markdown headings already have server-generated ids.
|
||||
(function () {
|
||||
var body = document.querySelector('[data-article-body]');
|
||||
var bar = document.querySelector('[data-reading-progress]');
|
||||
|
||||
if (body) {
|
||||
body.querySelectorAll('h2[id]').forEach(function (h2) {
|
||||
var a = document.createElement('a');
|
||||
a.href = '#' + h2.id;
|
||||
a.className = 'heading-anchor';
|
||||
a.textContent = '#';
|
||||
a.setAttribute('aria-label', 'Link to this section');
|
||||
h2.appendChild(a);
|
||||
});
|
||||
}
|
||||
|
||||
if (body && bar) {
|
||||
var raf = 0;
|
||||
var update = function () {
|
||||
var rect = body.getBoundingClientRect();
|
||||
var total = rect.height - window.innerHeight;
|
||||
var read = Math.min(Math.max(-rect.top, 0), Math.max(total, 1));
|
||||
bar.style.width = (total > 0 ? (read / total) * 100 : 100) + '%';
|
||||
};
|
||||
update();
|
||||
window.addEventListener('scroll', function () {
|
||||
if (raf) return;
|
||||
raf = requestAnimationFrame(function () { raf = 0; update(); });
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
// Scroll-spy for the right rail.
|
||||
var links = Array.prototype.slice.call(document.querySelectorAll('[data-chapter-link]'));
|
||||
if (body && links.length) {
|
||||
var h2s = Array.prototype.slice.call(body.querySelectorAll('h2[id]'));
|
||||
var baseClass = 'group flex items-baseline gap-3 py-1.5 px-2 -mx-2 rounded-md transition-colors duration-300 ease-out';
|
||||
var currentId = null;
|
||||
var setActive = function (id) {
|
||||
if (id === currentId) return;
|
||||
currentId = id;
|
||||
links.forEach(function (l) {
|
||||
var active = l.getAttribute('data-chapter-link') === id;
|
||||
l.className = baseClass + (active ? ' text-[#0369a1] font-semibold' : ' text-foreground/60 hover:text-[#0369a1]');
|
||||
});
|
||||
};
|
||||
var onScroll = function () {
|
||||
var probe = window.innerHeight * 0.35;
|
||||
var active = h2s.length ? h2s[0].id : null;
|
||||
for (var i = 0; i < h2s.length; i++) {
|
||||
var rect = h2s[i].getBoundingClientRect();
|
||||
if (rect.top - probe <= 0) active = h2s[i].id; else break;
|
||||
}
|
||||
if (active) setActive(active);
|
||||
};
|
||||
onScroll();
|
||||
var raf2 = 0;
|
||||
window.addEventListener('scroll', function () {
|
||||
if (raf2) return;
|
||||
raf2 = requestAnimationFrame(function () { raf2 = 0; onScroll(); });
|
||||
}, { passive: true });
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</Layout>
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import { render } from 'astro:content';
|
||||
import BlogPostLayout from '../../layouts/BlogPost.astro';
|
||||
import { getPublishedPosts } from '../../lib/blog';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getPublishedPosts();
|
||||
// posts are newest-first; prev = newer, next = older.
|
||||
return posts.map((post, i) => ({
|
||||
params: { id: post.id },
|
||||
props: { post, prev: posts[i - 1], next: posts[i + 1] },
|
||||
}));
|
||||
}
|
||||
|
||||
const { post, prev, next } = Astro.props;
|
||||
const { Content, headings } = await render(post);
|
||||
---
|
||||
<BlogPostLayout post={post} prev={prev} next={next} headings={headings}>
|
||||
<Content />
|
||||
</BlogPostLayout>
|
||||
Reference in New Issue
Block a user