@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.
Maintainers
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/ogImageare per-post SEO overrides. IngenerateMetadata, use the fallback chains shown above — that is the convention every VulcanTech site follows.metadatais a case-study spec sheet (e.g.Make: 1969 Camaro,Engine: LS3). The CMS only offers the editor whenpostTypeis"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 returnpublishedposts (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 ath2by convention — the editor toolbar offers H2–H6 andh1is left for your<h1>{post.title}</h1>— though the sanitizer permitsh1, so imported/legacy content may carry one. There are no task lists.Inline:
strong/em/u/s, inlinecode,sub/sup/mark, anda— links are written withtarget="_blank" rel="noopener noreferrer"already set.Images arrive as plain
<img>withclass="rounded-lg my-4 max-w-full h-auto"and no width/height attributes. Two consequences:next/imageis not usable insidecontent(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,BlogCarouselfromui/src/portfolioreadsdata-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
styleattributes 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 anisBlogPostpredicate (see Scoped routes below); passroutePrefixonly 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 libraryThe 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.
