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

v1.3.1

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

npm version CI license

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 several sites, including 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

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. The JSON-LD image field built by buildBlogPostingSchema is the one exception - it always points at the original heroImage file, not the .webp, since the original is the only file this package's contract guarantees exists.

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, postHref, serializeForScriptTag, 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 |

Everything that builds post URLs takes the route prefix as base (default /blog): the PostCard, RelatedPosts, Pagination and BlogPostMeta props, and the buildBlogPostingSchema and buildRssItems options. BlogPostMeta and the two helpers still accept the older basePath spelling, deprecated and removed in 2.0.

PostCard, RelatedPosts, Pagination, and BlogPostMeta accept an optional locale prop (default 'en') that localizes their built-in UI strings (dates, "Read More", pagination labels) and BlogPostMeta's JSON-LD inLanguage field. Locale ships built-in strings for 'en' | 'es' | 'pt' (autocompleted in editors) but accepts any string - a region-qualified locale like 'pt-BR' or 'es-CR' resolves to its primary subtag's strings (pt, es), and a locale with no shipped subtag falls back to the en strings. The raw string is always passed through to Intl.DateTimeFormat for date formatting, which handles regions on its own. Call t(locale, overrides) directly with a Partial<Strings> to supply your own strings for a locale the package doesn't ship. 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.

Subheading accepts an optional tone?: 'accent' | 'muted' prop (default 'accent') mapping to the same --accent/--muted token custom properties every other component uses. Its older color?: 'blue' | 'gray' prop is deprecated ('blue' behaves as 'accent', 'gray' as 'muted') and will be removed in 2.0 - it named a literal color rather than a token, which is misleading on any site whose accent isn't actually blue.

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.

Trailing slash

Every URL-building surface in this package defaults to no trailing slash and accepts a trailingSlash prop/option to opt in: BlogPostMeta (JSON-LD's url/mainEntityOfPage.@id), PostCard, RelatedPosts, Pagination (every page link: numbered, first, previous, next and last, including page 1's own href), and buildRssItems (each item's link). If your site's actual canonical post URL is slash-terminated, pass trailingSlash={true} (or { trailingSlash: true } for buildRssItems) to every one of these - passing it to only one (e.g. just BlogPostMeta) leaves the rest emitting slash-less URLs that disagree with JSON-LD and your page's own <link rel="canonical">, which can cause search engines to pick the wrong canonical form. Check your real canonical output before setting this, not just your app's trailingSlash config: prerendered routes on some hosts are served slash-terminated regardless of that config (confirm with curl -sI on a bare post URL - a 307/308 to the slash form means you need trailingSlash={true}).

Building your own URLs or JSON-LD

postHref(base, id, trailingSlash) is the exact helper PostCard, RelatedPosts, buildRssItems, and buildBlogPostingSchema use internally to build a post's URL (Pagination builds page links, not post links, but strips trailing slashes from base the same way). Reach for postHref directly when building your own card, archive list, or sitemap entry, instead of reimplementing the same ${base}/${id} concatenation - that duplication is how the original trailing-slash drift covered above happened.

serializeForScriptTag(value) safely serializes a value for a <script type="application/ld+json"> tag - JSON.stringify alone can emit a literal </script> inside a string field, which breaks the page. BlogPostMeta already uses it internally; call it yourself when embedding a second JSON-LD block at the layout level (e.g. WebSite/Organization schema next to BlogPostMeta's BlogPosting):

---
import { serializeForScriptTag } from '@vdaluz/astro-blog';

const websiteSchema = { '@context': 'https://schema.org', '@type': 'WebSite', name: 'My Site' };
---
<script type="application/ld+json" set:html={serializeForScriptTag(websiteSchema)} />

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.

Releasing

Maintainer-only. Releases are tag-triggered and published to npm via GitHub Actions (Trusted Publishing / OIDC, no token secret):

  1. Test before tagging: npm pack, install the tarball into a scratch Astro app (or a consumer locally), astro check && astro build.
  2. Bump the version with npm version X.Y.Z --no-git-tag-version, which updates package.json and package-lock.json together, then commit both.
  3. Tag vX.Y.Z and push the tag. Pushing the tag runs .github/workflows/publish.yml, which type-checks, tests, verifies the tag matches package.json's version, and only then runs npm publish.
  4. Confirm the version is live: npm view @vdaluz/astro-blog version. Consumers bump their own semver pin once it's confirmed live - see this package's CHANGELOG.md for what changed.

Consumers

License

MIT