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

v0.1.3

Published

CMS-driven announcement banners for consumer sites — top/bottom positions, page targeting, priority ordering, and dismissible bars backed by localStorage

Readme

@vulcantech/banners

CMS-driven announcement banners for VulcanTech consumer sites. Renders top/bottom bars fetched from the CMS, with page targeting, priority ordering, and per-user dismissal (localStorage).

Exports: CMSBanners (drop-in layout component), BannerBar (single bar), and the BannerData type.

Display only — this package ships no fetcher. You own the server fetch (step 3 below) and pass the result in as props.

Banner shape (BannerData)

message and linkText are plain text, not HTML — they are rendered as React children (<span>{banner.message}</span>), so any markup in them is escaped and shown literally. There is no HTML field on a banner and nothing here is ever injected with dangerouslySetInnerHTML.

interface BannerData {
  id: string;                    // Firestore doc id
  position: "top" | "bottom";    // which bar this belongs to — you render both
  message: string;               // plain text — see above
  linkText?: string;             // plain text; a "→" is appended by the renderer
  linkUrl?: string;              // plain <a href>, same tab, no next/link
  backgroundColor: string;       // raw CSS color, applied inline — required
  textColor: string;             // raw CSS color, applied inline — required
  icon?: string;                 // preset icon NAME, not a URL — not rendered here
  dismissible: boolean;          // shows the ✕ button
  /** Key used for dismiss localStorage. Reset in CMS to re-show to users. */
  dismissKey?: string;           // rotates on save — see below
  priority: number;              // ascending: lower numbers render first
  /** Page slugs where this banner appears. Empty = all pages. */
  pages?: string[];              // needs `currentPath` to have any effect
}

Field notes:

  • message / linkText are plain text; linkUrl renders as a bare <a href> in the same tab with no target or rel. The CMS rejects javascript:, data: and vbscript: URLs on write, so the stored value is a navigable URL.
  • backgroundColor / textColor are whatever string an editor saved (the CMS presets store hex like #2563eb) and go straight into style. They are required — a banner never falls back to a themed default, so this package deliberately ignores your Tailwind theme. Don't expect token classes to apply.
  • icon is a preset icon name ("info", "alert", "sparkles", "gift", "wrench", "rocket", "alert-triangle"), not an image URL. BannerBar does not render it — only the CMS dashboard preview maps those names to icons. Treat it as advisory metadata if you build your own bar.
  • dismissKey vs id — dismissal is keyed on dismissKey || id (the fallback is for banners created before the field existed). The CMS regenerates dismissKey on every save from the editor's default "Save & Reset Dismiss" button, which re-shows the banner to everyone who dismissed it; the "Save without resetting" option in that button's dropdown preserves it. So an edited banner reappearing for users is intended behavior, not a cache bug.
  • priority sorts ascending — 0 renders above 10. The CMS already returns banners in this order and CMSBanners sorts again, so the order is the same whichever way you render them.
  • pages is a list of path prefixes (["/", "/services", "/blog"]); empty or absent means every page. Matching is case-insensitive, ignores trailing slashes, and matches subtrees — "/blog" also matches /blog/my-post, while "/" matches only the root. Targeting is inert unless you pass currentPath: with no currentPath every banner passes the filter, including page-targeted ones. Note that the CMS's current /api/banners write validators do not persist this field, so banners authored through the dashboard today arrive with pages absent — build against the semantics above, but don't expect live data in it yet.

BannerData is a display subset of the CMS's internal Banner record. The API response also carries projectId, preset, active, startDate, endDate, createdAt and updatedAt; they are typed away here because the display components never need them. active and the date window are applied server-side (below), so there is nothing to filter yourself.

Fetching and failure mode

GET /api/banners?projectId=…&apiKey=… returns { banners: BannerData[] } for both positions at once, already filtered to active === true and inside the startDate/endDate window, sorted by priority ascending. Fetch it once in the layout and hand the same array to both <CMSBanners position="top"> and position="bottom" — do not fetch twice.

Two consequences of the server-side date filter:

  • Scheduling resolves at fetch time, not render time. A banner starts or expires only as fast as your ISR window (60s in the fetcher below) plus the CMS's own s-maxage=60. The CMS purges the banners:{projectId} tag on every create/update/delete, so editor changes land immediately; a startDate passing on its own does not trigger a purge.
  • Dates are ISO 8601 UTC strings and are compared as strings by the CMS, which is correct for the toISOString() values its editor writes.

Nothing in this package throws. The components have no fetching and no error path: CMSBanners returns null when nothing matches the position/path filter, and BannerBar returns null when the banner is dismissed or before mount. The recommended fetcher swallows every failure (unconfigured env, non-2xx, network error) and returns []. So there is no error UI to write — a broken CMS looks exactly like "no banners".

Rendering behavior

  • Both components are "use client". BannerBar renders null until it has mounted (it must read localStorage to know what was dismissed), so banners are never in the server-rendered HTML — they appear after hydration. Expect a small shift at the top of the page and don't rely on banner text for SEO.
  • CMSBanners wraps the top group in a position: relative; z-index: 51 div in normal flow, and the bottom group in position: sticky; bottom: 0; z-index: 40. A site whose header is position: fixed will cover the top banner — offset the header yourself (measure the rendered height and push the header down; the CMS does this with a ResizeObserver and a CSS variable).
  • Dismissals persist to one shared localStorage key, vulcantech_dismissed_banners (a JSON array of dismiss ids, capped at the 50 most recent). It is per-browser, not per-user account, and the cap means a site with heavy banner turnover eventually forgets the oldest dismissals. Writes are wrapped in try/catch, so a full or blocked storage quota degrades to "the banner comes back", never an exception.
  • Flipping dismissible to false re-shows the banner to everyone: the dismissal lookup only runs for dismissible banners.

Integration

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

1. Add the dependency:

npm install @vulcantech/banners

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

2. Set the env — see Required env below.

3. Create the server fetcher src/lib/cms/banners.ts (ISR-cached, fails open):

import type { BannerData } from "@vulcantech/banners";

const CMS_URL = process.env.VULCANTECH_CMS_URL || "https://cms.vulcantech.io";
const PROJECT_ID = process.env.VULCANTECH_PROJECT_ID;
const API_KEY = process.env.VULCANTECH_API_KEY;

export async function fetchBanners(): Promise<BannerData[]> {
  if (!PROJECT_ID || !API_KEY) return [];
  const params = new URLSearchParams({ projectId: PROJECT_ID, apiKey: API_KEY });
  try {
    const res = await fetch(`${CMS_URL}/api/banners?${params}`, {
      next: { revalidate: 60, tags: [`banners:${PROJECT_ID}`] },
    });
    if (!res.ok) return [];
    const json = (await res.json()) as { banners: BannerData[] };
    return json.banners ?? [];
  } catch {
    return [];
  }
}

4. Render in the root layout (src/app/layout.tsx):

import { CMSBanners } from "@vulcantech/banners";

const banners = await fetchBanners();
// <body>
//   <CMSBanners banners={banners} position="top" />
//   {children}
//   <CMSBanners banners={banners} position="bottom" />
// </body>

One fetch feeds both positions — the API returns top and bottom banners together.

4b. (Optional) page targeting — pages is only honored when CMSBanners receives currentPath. A root layout is a server component and has no pathname, so pass it from a thin client wrapper:

"use client";
import { usePathname } from "next/navigation";
import { CMSBanners, type BannerData } from "@vulcantech/banners";

export function SiteBanners({ banners }: { banners: BannerData[] }) {
  const pathname = usePathname();
  return (
    <>
      <CMSBanners banners={banners} position="top" currentPath={pathname} />
      <CMSBanners banners={banners} position="bottom" currentPath={pathname} />
    </>
  );
}

Without this, every banner shows on every page.

5. 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 (the list serves every integration, not just forms). The CMS purges the banners:{projectId} tag when a banner is edited. Route contract and reference copy: the @vulcantech/cms README, ISR revalidation.

Required env

VULCANTECH_PROJECT_ID, VULCANTECH_API_KEY (required) · VULCANTECH_REVALIDATE_SECRET (required for instant ISR purges; see step 5) · VULCANTECH_CMS_URL (optional, defaults to https://cms.vulcantech.io).

Per-app values

None beyond env — banners is fully config-driven from the CMS.

Notes

  • Components are "use client"; dismissal persists to localStorage (vulcantech_dismissed_banners). See Rendering behavior for the hydration, stacking, and dismissal-cap details.