@vdaluz/astro-blog
v0.10.0
Published
Token-driven Astro blog components, related-posts scoring, a schema factory, and Shiki config - proven in production on vdaluz.com and imperfectsystems.com.
Maintainers
Readme
@vdaluz/astro-blog
A blog needs a listing page, pagination, related posts, tag filters, JSON-LD, RSS, and a Shiki-highlighted code theme - most of it undifferentiated work you rebuild every time you spin up an Astro site. @vdaluz/astro-blog packages that layer as token-driven components, so styling comes from your own CSS custom properties, not a hardcoded palette. Ships raw .astro and .ts - the consuming app's Astro/Vite compiles them (no prebuild step). Built for and proven in production across two sites, vdaluz.com and imperfectsystems.com - see Consumers.
Scope: this is a component library, not a drop-in blog. Routes (
src/pages/blog/*) and content (src/content/blog/*.md) stay in each app - see Per-app glue.
Install
npm install @vdaluz/astro-blogAlternatively, a pinned https tarball from a tag works too, with no registry involved:
// package.json
"dependencies": {
"@vdaluz/astro-blog": "https://github.com/vdaluz/astro-blog/archive/refs/tags/v0.9.0.tar.gz"
}Why a tarball, not
github:vdaluz/astro-blog#v0.1.0? npm canonicalizes GitHub shorthand (and even an explicitgit+https://URL) togit+ssh://in the lockfile. CI runners (e.g. Cloudflare Pages/Workers) have no SSH key, sonpm ciwould fail to clone it. The/archive/refs/tags/<tag>.tar.gzURL is anonymous https with an integrity hash in the lockfile - it just works in CI. Bump the tag in the URL to upgrade.
Peer dependency: astro >= 6. For post body styling you'll also want @tailwindcss/typography in the app.
Four things every consumer MUST do
Define the token CSS variables. Components reference only these names:
bg,surface,surface-muted,fg,muted,border,accent,accent-strong,accent-soft,on-accent. Copysrc/styles/tokens.example.cssinto your app and set your palette.Alias the tokens in
tailwind.config.mjsAND scan the package (this glob is the #1 thing people forget - without it the package's utility classes are never generated):export default { content: [ './src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}', './node_modules/@vdaluz/astro-blog/**/*.{astro,ts}', // <-- required ], theme: { extend: { colors: { bg: 'rgb(var(--bg) / <alpha-value>)', surface: 'rgb(var(--surface) / <alpha-value>)', 'surface-muted': 'rgb(var(--surface-muted) / <alpha-value>)', fg: 'rgb(var(--fg) / <alpha-value>)', muted: 'rgb(var(--muted) / <alpha-value>)', border: 'rgb(var(--border) / <alpha-value>)', accent: 'rgb(var(--accent) / <alpha-value>)', 'accent-strong': 'rgb(var(--accent-strong) / <alpha-value>)', 'accent-soft': 'rgb(var(--accent-soft) / <alpha-value>)', 'on-accent': 'rgb(var(--on-accent) / <alpha-value>)', }, }, }, plugins: [require('@tailwindcss/typography')], };Wire Shiki in
astro.config.mjsand ship the matching CSS handoff (included intokens.example.css):import { shikiConfig } from '@vdaluz/astro-blog'; export default defineConfig({ markdown: { shikiConfig } });The
shikiConfigusesdefaultColor: false, so the CSS handoff is what actually colors code blocks. They must ship together. Dark-only sites: keepshikiConfigand force<html class="dark">so the dark vars always apply.Generate a matching
.webpsibling for everyheroImage.PostCardandRelatedPostsderive the thumbnailsrcby swapping theheroImageextension (.jpg/.jpeg/.png/.gif) for.webp- they never render the raw file. If a post setsheroImage: /assets/images/foo.jpeg,assets/images/foo.webpmust exist at that same path or the thumbnail 404s. Any image pipeline that outputs a same-basename.webpnext to the original works (e.g. a Sharp-based build step); nothing in this package generates it for you.
Example
PostCard rendering real posts on vdaluz.com's /blog listing:

Exports
| Import | What |
| --- | --- |
| @vdaluz/astro-blog | blogSchema, buildBlogPostingSchema, scoreRelated, normalizeTag, filterPostsByTag, shikiConfig, buildRssItems, t, formatDate, types |
| @vdaluz/astro-blog/PostCard.astro | Post card for listings |
| @vdaluz/astro-blog/RelatedPosts.astro | Related-posts grid |
| @vdaluz/astro-blog/Pagination.astro | Paginated listing nav |
| @vdaluz/astro-blog/Subheading.astro | Small uppercase section label |
| @vdaluz/astro-blog/BlogPostMeta.astro | JSON-LD BlogPosting <script> |
| @vdaluz/astro-blog/HeroImageCredit.astro | Photographer/source/license attribution line for a post's heroImageCredit |
| @vdaluz/astro-blog/TagFilterNav.astro | Filter chip nav (e.g. by project or topic tag) |
| @vdaluz/astro-blog/TableOfContents.astro | "On this page" nav from a post's headings array (sticky sidebar on desktop, <details> on mobile) |
| @vdaluz/astro-blog/remark | remarkReadingTime - writes minutesRead to the page's frontmatter |
Components that build post URLs (PostCard, RelatedPosts, Pagination) accept an optional base prop (default /blog).
PostCard, RelatedPosts, Pagination, and BlogPostMeta accept an optional locale prop ('en' | 'es', default 'en') that localizes their built-in UI strings (dates, "Read More", pagination labels) and BlogPostMeta's JSON-LD inLanguage field. It does not affect the post URLs those components build - a locale-specific base still needs passing separately if the consuming app routes translated posts under a different prefix (e.g. /es/blog).
PostCard accepts an optional categoryLabel prop to override the category badge text (default post.data.category). RelatedPosts accepts the same override as a (post) => string function, since it renders a badge per post. Use these when category is a canonical/English taxonomy value that the consuming app translates for display - the package has no built-in category translation since the taxonomy itself is app-defined.
Hero image attribution
blogSchema() validates an optional heroImageCredit field (name, url, source: 'pexels' | 'unsplash' | 'openverse', optional licenseName/licenseUrl) for posts whose hero image needs attribution - required for CC/attribution-required sources like Openverse, not just polite. Render it with HeroImageCredit:
---
import HeroImageCredit from '@vdaluz/astro-blog/HeroImageCredit.astro';
---
{entry.data.heroImageCredit && (
<HeroImageCredit credit={entry.data.heroImageCredit} locale={locale} />
)}Updated dates
Set updatedDate in a post's frontmatter when you substantively edit it after publishing. buildBlogPostingSchema uses it for the JSON-LD dateModified field, falling back to pubDate when unset - so an edited post can signal freshness without every post needing the field.
Table of contents + reading time
TableOfContents reads the headings array Astro's own render() already returns - no separate parsing step. It renders nothing if the post has fewer than minHeadings (default 3) h2/h3 headings.
// astro.config.mjs
import { remarkReadingTime } from '@vdaluz/astro-blog/remark';
export default defineConfig({ markdown: { remarkPlugins: [remarkReadingTime] } });---
import TableOfContents from '@vdaluz/astro-blog/TableOfContents.astro';
import { t } from '@vdaluz/astro-blog';
const { Content, headings, remarkPluginFrontmatter } = await render(entry);
const strings = t(locale);
---
<span>{strings.minRead(remarkPluginFrontmatter.minutesRead)}</span>
<TableOfContents headings={headings} locale={locale} />TableOfContents anchors each entry to #<slug> - the consuming app's markdown-to-HTML pipeline must emit matching id attributes on the rendered <h2>/<h3> tags (Astro/rehype does this by default; verify if a custom rehype config strips heading ids).
Per-app glue
Each site keeps these - they can't be packaged because they bind to the app's own collection and routes.
src/content.config.ts:
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { blogSchema } from '@vdaluz/astro-blog';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
schema: blogSchema({ defaultAuthor: 'Your Name' }),
});
export const collections = { blog };src/pages/blog/[...page].astro and [...slug].astro: do getCollection / getStaticPaths / render() in-app (tied to your collection), then render the package components. Keep export const prerender = true. Site-specific headings and CTAs live here.
Related posts:
import { scoreRelated } from '@vdaluz/astro-blog';
const related = scoreRelated(entry, allPosts, { k: 3, aliases: { ha: 'home-assistant' } });Tag/project filtering: the route (src/pages/blog/tag/[tag].astro or similar) is per-app since URL
shape and how you build the tag list are site-specific. filterPostsByTag + Pagination do the rest -
Pagination's base prop already works with any route prefix, no changes needed for a filtered route:
import { filterPostsByTag } from '@vdaluz/astro-blog';
export const getStaticPaths: GetStaticPaths = async ({ paginate }) => {
const all = await getCollection('blog');
const filtered = filterPostsByTag(all, 'homelab').sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime()
);
return paginate(filtered, { pageSize: 5 });
};<Pagination page={page} base="/blog/tag/homelab" />TagFilterNav renders the filter chips themselves - build the options array (label, href, whether
it's the active filter) from whatever tag list your app tracks:
<TagFilterNav
options={[
{ label: 'All', href: '/blog', active: !tag },
{ label: 'Homelab', href: '/blog/tag/homelab', active: tag === 'homelab' },
]}
/>RSS feed (src/pages/rss.xml.ts, needs the app's own @astrojs/rss dependency - this package doesn't ship it):
import rss from '@astrojs/rss';
import type { APIRoute } from 'astro';
import { getCollection } from 'astro:content';
import { buildRssItems } from '@vdaluz/astro-blog';
export const prerender = true;
export const GET: APIRoute = async (context) => {
const now = new Date();
const posts = (await getCollection('blog'))
.filter((p) => p.data.pubDate <= now)
.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());
return rss({
title: 'Your Site - Blog',
description: 'Your site description',
site: context.site!,
items: buildRssItems(posts),
});
};Contributing
Issues welcome. PRs by discussion - open an issue first for anything beyond a typo or docs fix.
