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/collections

v0.2.0

Published

Server-side portfolio/collections fetch layer for VulcanTech consumer sites — ISR-tagged reads from the CMS with boundary HTML sanitization. Shared so each site doesn't duplicate the fetch code.

Readme

@vulcantech/collections

Server-side portfolio/collections fetch layer for VulcanTech consumer sites. Mirrors @vulcantech/blog: ISR-tagged reads from the CMS with the rich-text body sanitized at this boundary so every consumer render is safe by default.

Configuration

Set these env vars on the consumer site (server-only — no NEXT_PUBLIC_ prefix):

| Var | Required | Notes | |---|---|---| | VULCANTECH_CMS_URL | no | Defaults to https://cms.vulcantech.io | | VULCANTECH_PROJECT_ID | yes | The CMS project id | | VULCANTECH_API_KEY | yes | API key with the portfolio:read scope |

API

import {
  fetchCollectionItems,
  fetchCollectionItemBySlug,
  fetchCollections,
  type PortfolioItemData,
  type PortfolioCollection,
} from "@vulcantech/collections";

// All published items, or just one collection
const items = await fetchCollectionItems();
const gallery = await fetchCollectionItems({ collection: "gallery" });

// One item — pass `collection` to disambiguate cross-collection slug collisions
const item = await fetchCollectionItemBySlug("engine-bay-reset", "gallery");

// Collection groups (each carries an optional defaultDisplayMode)
const collections = await fetchCollections();

Item shape (PortfolioItemData)

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

interface PortfolioItemData {
  id: string;                      // Firestore doc id
  collectionSlug?: string;         // absent only on legacy items (CMS writes "default")
  title: string;
  slug: string;                    // lowercase-hyphenated; unique only WITHIN a collection
  description: string;             // plain text, never HTML — render as text
  category?: string;               // free-form editor text, not an enum (vs free-form tags)
  tags: string[];
  images: PortfolioImage[];        // { url; alt?; caption? } — absolute media-host URLs
  beforeAfterPairs: BeforeAfterPair[]; // { before; after; caption? } — before/after are image URLs
  featuredImage?: string;          // a URL, not an index; always one of images[].url
  featured: boolean;               // editorial "pin me" flag; does NOT affect ordering
  linkType: PortfolioLinkType;     // "none" | "blog" | "detail" — see below
  linkedBlogSlug?: string;         // only meaningful when linkType === "blog"
  location?: string;               // free-form address/locality
  details: PortfolioDetail[];      // { label; value } — per-project spec sheet, no fixed schema
  challenges: string[];            // case-study bullet lists
  solutions: string[];
  testimonial?: PortfolioTestimonial;  // { quote; author; role? }
  video?: PortfolioVideo;          // advisory metadata; most items have none (see below)
  completedDate?: string;          // "YYYY-MM-DD" or "YYYY-MM" — NOT an ISO datetime
  content?: string;                // sanitized HTML — see above; "" when never written
  displayMode?: "gallery" | "before-after"; // absent only on legacy items
}

Field notes:

  • linkType drives click behaviour: "none" = grid image with no link, "blog" = link to the blog post named by linkedBlogSlug, "detail" = the item has its own page rendered from content + the case-study fields. The case-study fields (content, challenges, solutions, testimonial, video, details) are only offered in the dashboard for "detail" items but are never cleared when the type changes — so branch on linkType rather than inferring intent from a field being present.
  • featuredImage is a URL, not an index. The dashboard keeps it inside images (defaulting to the first image added), so featuredImage ?? images[0]?.url is a safe hero fallback.
  • Image URLs are absolute CMS media-library URLs on firebasestorage.googleapis.com. Using them with next/image requires that host in images.remotePatterns in next.config, or the render throws.
  • completedDate is a date string, not a timestamp — "2025-03-14", or "2025-03" when the editor ticked "month only". Format defensively; don't assume day precision.
  • details is a free-form label/value sheet whose meaning is per project (rates, sqft, bedrooms, duration…). There is no fixed schema — filter by label if you need a subset.
  • content is "", not undefined, on items created and left empty, so branch on truthiness rather than undefined.
  • Only published items are ever returned, so you never need to filter by status. The API returns the stored document unprojected — extra CMS fields (projectId, status, order, createdAt, updatedAt) are present on the wire but deliberately absent from the type. Don't reach for them.

Rendering content (TipTap output)

Three things the HTML can contain that need consumer-side work:

  • Tables. Staff author tables in the editor, and style attributes are stripped on write — so all table styling must come from your CSS. Two gotchas: TipTap wraps every cell's text in a <p> (reset its margin), and tables are non-resizable so no column-width attributes arrive. See apps/castlerents/src/components/packages/PackageContent.tsx for a worked example.
  • Image galleries arrive as an empty placeholder — <div class="cms-gallery" data-gallery='[{"url":"…","caption":"…"}]'></div> — and render as nothing unless you hydrate them. Inside the monorepo, mount BlogCarousel from ui/portfolio against the container ref.
  • Inline videos arrive as real, playable markup (<video class="cms-video"> for CMS-hosted files, a pinned youtube-nocookie.com <iframe class="cms-video"> for YouTube, wrapped in <figure class="cms-video-figure"> when captioned), so a site that does nothing still plays them — but they are unsized until you add CSS for .cms-video / .cms-video--short / .cms-video-figure. See @vulcantech/blog for the canonical snippet.

Fetching, filters and failure mode

fetchCollectionItems({ collection }) takes one filter — the collection slug. There is no limit, cursor, tag, category, search or featured filter, and no pagination: the call returns every published item in scope, so narrow further in your own code. Results are ordered by createdAt descending (newest first). Note that the dashboard's drag-to-reorder writes an order field that this endpoint does not sort by and that is not part of PortfolioItemData — so reordering in the CMS does not change consumer output. Sort client-side if you need a specific sequence.

fetchCollectionItemBySlug(slug, collection) — always pass collection. Slugs are unique only per-collection, so a bare slug can resolve a same-slug item from another collection. fetchCollections() returns the project's collection groups ordered by createdAt ascending.

Caching is fixed: every request is tagged and revalidated at 60 seconds. Unlike @vulcantech/blog there are no per-call revalidate / noStore / extraTags options, and VULCANTECH_BLOG_NO_CACHE does not apply here. The tags attached are portfolio:{projectId} on every call, plus collection:{projectId}:{collectionSlug} on a scoped list and item:{projectId}:{slug} on a single item — tagsForCollection and tagsForCollectionItem are exported so you can build the same strings to purge.

The fetchers do not throw on CMS failure. An unreachable host, a non-2xx response, a 404, or missing env config all resolve to [] / null after a console.warn. So a try/catch error UI around them can never fire: handle the empty list on index pages and call notFound() on a null item instead. (The one residual throw is a 2xx response whose body isn't JSON, which no healthy CMS returns.)

Rendering displayMode

Each item carries displayMode: "gallery" | "before-after". Branch on it to choose how to render — a standard image gallery, or a before/after slider fed from item.beforeAfterPairs. Fall back to the collection's defaultDisplayMode for legacy items that have no explicit mode:

const mode = item.displayMode ?? collection?.defaultDisplayMode ?? "gallery";

item.content is sanitized HTML — render with dangerouslySetInnerHTML.

Case-study video (item.video)

Case-study items (linkType: "detail") may carry an optional video — structured metadata, not markup, so your site decides the treatment: a hero player, a card thumbnail, or nothing at all. Most items have none; branch on it.

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

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";

{item.video && <VideoEmbed video={item.video} showCaption className="rounded-xl" />}

Videos placed within the case-study body arrive as playable markup inside item.content instead — that path needs no code at all.

Integration

  1. npm install @vulcantech/collections (inside the VulcanTech monorepo, use the workspace wiring in MONOREPO.md instead).
  2. Set the env vars above.
  3. ISR invalidation — unlike blog, forms and banners, the CMS has no revalidation dispatcher for collections: no webhook fires on a collection edit, so nothing purges these tags for you. Dashboard edits go live when the 60s ISR window lapses. If you need faster invalidation, purge tagsForCollection(...) / tagsForCollectionItem(...) from your own src/app/api/revalidate/route.ts.