npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

Readme

@vdaluz/astro-blog

CI

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-blog

Alternatively, 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 explicit git+https:// URL) to git+ssh:// in the lockfile. CI runners (e.g. Cloudflare Pages/Workers) have no SSH key, so npm ci would fail to clone it. The /archive/refs/tags/<tag>.tar.gz URL 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

  1. Define the token CSS variables. Components reference only these names: bg, surface, surface-muted, fg, muted, border, accent, accent-strong, accent-soft, on-accent. Copy src/styles/tokens.example.css into your app and set your palette.

  2. Alias the tokens in tailwind.config.mjs AND 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')],
    };
  3. Wire Shiki in astro.config.mjs and ship the matching CSS handoff (included in tokens.example.css):

    import { shikiConfig } from '@vdaluz/astro-blog';
    export default defineConfig({ markdown: { shikiConfig } });

    The shikiConfig uses defaultColor: false, so the CSS handoff is what actually colors code blocks. They must ship together. Dark-only sites: keep shikiConfig and force <html class="dark"> so the dark vars always apply.

  4. Generate a matching .webp sibling for every heroImage. PostCard and RelatedPosts derive the thumbnail src by swapping the heroImage extension (.jpg/.jpeg/.png/.gif) for .webp - they never render the raw file. If a post sets heroImage: /assets/images/foo.jpeg, assets/images/foo.webp must exist at that same path or the thumbnail 404s. Any image pipeline that outputs a same-basename .webp next 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:

PostCard grid on vdaluz.com's blog listing page

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.

Consumers