diff --git a/site/src/components/blog/Callout.astro b/site/src/components/blog/Callout.astro new file mode 100644 index 00000000..3deb449e --- /dev/null +++ b/site/src/components/blog/Callout.astro @@ -0,0 +1,41 @@ +--- +/** + * Inline callout for MDX blog posts. + * + * import Callout from '../../components/blog/Callout.astro'; + * ... + * + * 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' }; +--- + + + diff --git a/site/src/layouts/BlogPost.astro b/site/src/layouts/BlogPost.astro new file mode 100644 index 00000000..c01a1513 --- /dev/null +++ b/site/src/layouts/BlogPost.astro @@ -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 }[]; +--- + + +
+
+ + + Blog + +
+ + + {reading} + {updatedDate && } + {updatedDate && } +
+
+
+
+ +
+ +
+
+
+
+ Blog + {tags.map((tag) => ( + {tag} + ))} +
+

{title}

+

{description}

+
+ + {author} + + +
+
+
+
+ + +
+
+
+ +
+ + + +
+
+ + + {neighbors.length > 0 && ( + + )} +
+ + + + +
diff --git a/site/src/pages/blog/[...id].astro b/site/src/pages/blog/[...id].astro new file mode 100644 index 00000000..7094823a --- /dev/null +++ b/site/src/pages/blog/[...id].astro @@ -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); +--- + + +