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

@vulcantech/blog

v0.2.2

Published

Server-side blog fetch layer for VulcanTech consumer sites — ISR-tagged reads from the CMS, with signed draft-preview support. Shared so each site doesn't duplicate the fetch code.

Readme

@vulcantech/blog

Server-side blog fetch layer for VulcanTech consumer sites. Reads published posts from the CMS with Next.js ISR tags, and supports signed draft-preview tokens so unpublished posts can be reviewed on the live site. Shared so each site stops duplicating lib/cms/blog.ts.

Exports: fetchBlogPosts, fetchBlogPostBySlug, resolvePreviewToken, GET (the draft-preview route handler), tagsForBlogList / tagsForBlogPost, sanitizeHtml, and the CMSBlogPost / CMSBlogAuthor / CMSVideoDescriptor / FetchOptions / FetchPostsOptions / FetchPostOptions types.

Server-only — uses VULCANTECH_API_KEY; import from server components / route handlers only.

Post shape (CMSBlogPost)

content is sanitized HTML (TipTap editor output), not markdown — render it with dangerouslySetInnerHTML, never through a markdown renderer. It is sanitized on write by the CMS and re-sanitized by this package's fetchers, so injecting it as-is is safe by default. The exported sanitizeHtml is the same boundary pass — only needed for CMS HTML obtained some other way.

interface CMSBlogPost {
  id: string;
  projectId: string;
  title: string;
  slug: string;
  content: string;               // sanitized HTML — see above
  excerpt: string;               // plain-text summary
  author: { uid?: string; name: string; avatar?: string }; // object, not a string
  tags: string[];
  category?: string;             // single editorial bucket (vs free-form tags)
  routePrefix?: string;          // URL space; absent = "blog" (see Scoped routes)
  keywords?: string;             // comma-separated SEO keywords, target keyword first
  featuredImage?: string;        // in-page hero / card image
  featuredVideo?: CMSVideoDescriptor; // advisory; most posts have none (see step 8)
  ogImage?: string;              // social card; fall back: ogImage ?? featuredImage
  seoTitle?: string;             // fall back: seoTitle ?? title
  seoDescription?: string;       // fall back: seoDescription ?? excerpt
  status: "draft" | "in_review" | "published" | "archived";
  publishedAt?: string;          // ISO 8601; always present on published posts
  scheduledAt?: string;          // ISO 8601
  createdAt: string;             // ISO 8601
  updatedAt: string;             // ISO 8601
  postType?: "article" | "case-study" | "announcement"; // absent = "article"
  metadata?: { label: string; value: string }[]; // case-study spec sheet — see below
}

Field notes:

  • seoTitle / seoDescription / ogImage are per-post SEO overrides. In generateMetadata, use the fallback chains shown above — that is the convention every VulcanTech site follows.
  • metadata is a case-study spec sheet (e.g. Make: 1969 Camaro, Engine: LS3). The CMS only offers the editor when postType is "case-study"; render it as a spec/details table on case-study layouts and ignore it everywhere else — do not dump it onto regular articles.
  • status — the public fetchers only ever return published posts (or a token-unlocked draft in preview mode), so you never need to filter by status yourself.

What's inside content

The editor is TipTap (StarterKit + a few nodes), and the CMS sanitizes on write against a fixed allowlist, so the markup vocabulary is small and known:

  • Blocks: p, h2–h6, ul/ol/li, blockquote, pre > code, hr, tables, details/summary (native toggles, used by documentation posts). Headings start at h2 by convention — the editor toolbar offers H2–H6 and h1 is left for your <h1>{post.title}</h1> — though the sanitizer permits h1, so imported/legacy content may carry one. There are no task lists.

  • Inline: strong/em/u/s, inline code, sub/sup/mark, and a — links are written with target="_blank" rel="noopener noreferrer" already set.

  • Images arrive as plain <img> with class="rounded-lg my-4 max-w-full h-auto" and no width/height attributes. Two consequences: next/image is not usable inside content (no dimensions, and it's raw HTML) — render the body as-is; and Tailwind cannot scan CMS content, so those utility classes only take effect if your build already emits them. Add a plain CSS rule to be safe (this is the most common overflow bug, more so than videos):

    .blog-content img { max-width: 100%; height: auto; border-radius: 0.5rem; margin: 1rem 0; }
  • Image galleries serialize to an empty <div class="cms-gallery" data-gallery='[…]'> — it renders as nothing unless you hydrate it. Inside the monorepo, BlogCarousel from ui/src/portfolio reads data-gallery; without a hydrator, expect a silent gap where the gallery was.

  • Tables arrive unstyled and non-resizable (no width attributes, every cell's text <p>-wrapped) — borders, padding, and overflow scrolling are entirely your CSS's job.

  • Inline styling does not survive. Both sanitizers strip style attributes and <style> tags on write, so any inline color/size/alignment an editor applies silently disappears on the consumer site. That is by design (CSS-injection surface) — editors reporting "my formatting vanished" are seeing this, not a bug in your site.

Because of all this, a typographic wrapper (@tailwindcss/typography's prose, or hand-written styles scoped to your content container) is expected — the HTML itself carries no presentation beyond the image classes above.

Fetching and failure mode

fetchBlogPosts(options?) returns posts newest-first by publishedAt — the CMS orders them; do not add your own sort. It accepts server-side filters — category, routePrefix, tag, search, limit, and startAfter (a pagination cursor: the id of the last post of the previous page) — plus the caching knobs revalidate (default 60s), extraTags, and noStore. Prefer these filters over fetching everything and filtering client-side, with one exception:

  • Don't pass routePrefix: "blog" for the blog itself. Legacy posts predate the field and have no prefix, so that filter would exclude them. Filter the blog list client-side with an isBlogPost predicate (see Scoped routes below); pass routePrefix only for non-blog scopes like "documentation".

Pagination: the response is a bare post list — there is no hasMore or total. To paginate, fetch limit + 1: if you get more than limit posts there is another page — drop the extra and pass the last kept post's id as the next startAfter.

The fetchers never throw. On any CMS error (unreachable, non-2xx, unconfigured env) they log a warning and return [] / null. So: no try/catch error UI — it can never fire. Instead, handle the empty list on index pages and call notFound() on a null post.

Integration

Wire blog into a consumer site (Next.js App Router):

1. Add the dependency:

npm install @vulcantech/blog

(Inside the VulcanTech monorepo, use the workspace wiring in MONOREPO.md instead.)

2. Set the env — see Required env below.

3. (Optional) thin re-export src/lib/cms/blog.ts to preserve @/lib/cms/blog import paths:

export * from "@vulcantech/blog";

4. Blog pages — list + single post (server components):

import { fetchBlogPosts, fetchBlogPostBySlug, resolvePreviewToken } from "@vulcantech/blog";

// app/blog/page.tsx
const posts = await fetchBlogPosts();

// app/blog/[slug]/page.tsx — resolve the preview token (Draft Mode) and pass it through
const previewToken = await resolvePreviewToken();
const post = await fetchBlogPostBySlug(slug, { previewToken });

In generateMetadata, prefer the post's derived keywords string (the CMS builds it from the post's dedicated SEO keyword fields — target keyword first) and fall back to tags:

keywords: post.keywords || (post.tags?.length ? post.tags : [post.title, "Brand"]),

5. Draft-preview route src/app/api/preview/route.ts — one line, shared implementation:

export { GET } from "@vulcantech/blog";

The CMS mints a signed token and links to /api/preview?token=…&slug=…; the handler validates it, enables Next Draft Mode, and redirects to the post. Reading the token opts the render dynamic — keep the public [slug] route static/ISR and let Draft Mode switch it.

6. ISR invalidation — ensure src/app/api/revalidate/route.ts exists, set VULCANTECH_REVALIDATE_SECRET, and register the site under Settings → Form Consumers (ISR Revalidation) in the CMS. That list serves every integration, not just forms — skip it and blog edits wait out the 60s window instead of purging instantly. The CMS purges blog:{projectId} (list) and blog:{projectId}:{slug} (single) on edit. Route contract and reference copy: the @vulcantech/cms README, ISR revalidation.

7. (Optional) video styling — authors can drop videos into post content. They arrive as real, playable markup (<video class="cms-video"> for CMS-hosted files, a pinned youtube-nocookie.com <iframe class="cms-video"> for YouTube), so a site that does nothing still plays them. Add this to your global CSS to size them:

.cms-video {
  display: block;
  width: 100%;
  max-width: 40rem;
  aspect-ratio: 16 / 9;
  border: 0;
  border-radius: 0.5rem;
}
/* YouTube Shorts — 9:16 */
.cms-video--short {
  max-width: 20rem;
  aspect-ratio: 9 / 16;
}
/* Only present when the author wrote a caption */
.cms-video-figure { margin: 1.5rem 0; }
.cms-video-figure figcaption { margin-top: 0.5rem; font-size: 0.875rem; opacity: 0.7; }

8. (Optional) featured video — a post may carry featuredVideo beside featuredImage: structured metadata, not markup, so your site decides the treatment (hero player, card thumbnail, or ignore it). Most posts have none.

type CMSVideoDescriptor =
  | { provider: "youtube"; videoId: string }
  | { provider: "youtube-short"; videoId: string }   // 9:16
  | { provider: "file"; url: string; poster?: string };  // CMS media library

The CMS pins both halves on write — videoId is safe to interpolate into a YouTube embed URL, and url is always on the media host — so there is nothing to re-validate before rendering. Inside the monorepo, ui ships a ready-made player rather than each site hand-rolling the iframe:

import { VideoEmbed } from "ui/src/video";

{post.featuredVideo && <VideoEmbed video={post.featuredVideo} className="rounded-xl" />}

This is separate from videos the author placed within the article — those arrive as playable markup inside content (step 7 above) and need no code.

Scoped routes (routePrefix)

Posts carry an optional routePrefix that decides which URL space they live in on the consumer site: "blog" → /blog/<slug> (the default; legacy posts with no prefix count as blog), "legal" → /legal/<slug>, "documentation" → /documentation/<slug>, etc. Editors set it per-post in the CMS dashboard. One Firestore collection powers all of them — the split happens at fetch/render time.

Wiring a scoped route (e.g. documentation) takes three pieces:

1. A scope predicate — keep /blog clean. fetchBlogPosts() returns every published post regardless of prefix, so the blog list/related-posts must filter, or scoped posts leak onto /blog. Centralize the predicate in one module (e.g. src/lib/cms/postScope.ts) instead of scattering !== "legal" checks:

export const BLOG_ROUTE_PREFIX = "blog";
/** Legacy posts predate the field, so no prefix also means "blog". */
export function isBlogPost(post: { routePrefix?: string }): boolean {
  return !post.routePrefix || post.routePrefix === BLOG_ROUTE_PREFIX;
}

New prefixes are then excluded from /blog automatically, with no further edits.

2. The scoped pages. List pages fetch with the prefix; the [slug] page must ALSO guard the prefix so a blog slug can never render on the scoped route:

// app/documentation/page.tsx
const docs = await fetchBlogPosts({ routePrefix: "documentation" });

// app/documentation/[slug]/page.tsx
const post = await fetchBlogPostBySlug(slug);
if (!post || post.routePrefix !== "documentation") notFound();

generateStaticParams should use the same fetchBlogPosts({ routePrefix }) call.

3. Revalidation. Nothing extra — the CMS purges the same blog:{projectId} tags on edit, so scoped pages revalidate with the blog as long as they use this package's fetchers (which attach those tags).

Required env

VULCANTECH_PROJECT_ID, VULCANTECH_API_KEY (required) · VULCANTECH_REVALIDATE_SECRET (required for instant ISR purges; see step 6) · VULCANTECH_CMS_URL (optional, default https://cms.vulcantech.io) · VULCANTECH_BLOG_NO_CACHE (optional, 1/true to bypass ISR while debugging).

Per-app values

None beyond env — all blog logic is shared.