@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.
Maintainers
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:
linkTypedrives click behaviour:"none"= grid image with no link,"blog"= link to the blog post named bylinkedBlogSlug,"detail"= the item has its own page rendered fromcontent+ 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 onlinkTyperather than inferring intent from a field being present.featuredImageis a URL, not an index. The dashboard keeps it insideimages(defaulting to the first image added), sofeaturedImage ?? images[0]?.urlis a safe hero fallback.- Image URLs are absolute CMS media-library URLs on
firebasestorage.googleapis.com. Using them withnext/imagerequires that host inimages.remotePatternsinnext.config, or the render throws. completedDateis 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.detailsis 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.contentis"", notundefined, on items created and left empty, so branch on truthiness rather thanundefined.- 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
styleattributes 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. Seeapps/castlerents/src/components/packages/PackageContent.tsxfor 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, mountBlogCarouselfromui/portfolioagainst the container ref. - Inline videos arrive as real, playable markup (
<video class="cms-video">for CMS-hosted files, a pinnedyoutube-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/blogfor 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
npm install @vulcantech/collections(inside the VulcanTech monorepo, use the workspace wiring inMONOREPO.mdinstead).- Set the env vars above.
- 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 ownsrc/app/api/revalidate/route.ts.
