@contentforge/sdk
v0.1.0
Published
TypeScript SDK for ContentForge headless CMS
Maintainers
Readme
@contentforge/sdk
TypeScript SDK for ContentForge headless CMS.
Zero dependencies. SSR-ready. TypeScript-first.
Installation
npm install @contentforge/sdkRequires Node.js 18+ (native fetch) or any environment with globalThis.fetch.
Quick Start
import { ContentForge } from '@contentforge/sdk'
const cms = new ContentForge({
url: 'https://cms.yourdomain.com',
apiKey: 'pk_live_xxx',
defaultLocale: 'en',
})
// List blog posts
const posts = await cms.entries('blog-post').list({
sort: '-published_at',
limit: 10,
})
// Get a single entry by slug
const post = await cms.entries('blog-post').getBySlug('hello-world', {
locale: 'en',
})Framework Examples
SvelteKit
// src/routes/blog/[slug]/+page.server.ts
import { ContentForge } from '@contentforge/sdk'
import { CMS_URL, CMS_API_KEY } from '$env/static/private'
const cms = new ContentForge({ url: CMS_URL, apiKey: CMS_API_KEY })
export async function load({ params, request }) {
const [post, seo] = await Promise.all([
cms.entries('blog-post').getBySlug(params.slug, { locale: 'en' }),
cms.entries('blog-post').seo(params.slug, { locale: 'en' }),
])
return { post, seo }
}<!-- src/routes/blog/[slug]/+page.svelte -->
<script>
import { seoToJsonLd } from '@contentforge/sdk'
export let data
</script>
<svelte:head>
<title>{data.seo.meta_title}</title>
<meta name="description" content={data.seo.meta_description} />
<link rel="canonical" href={data.seo.canonical_url} />
{#each data.seo.hreflang as h}
<link rel="alternate" hreflang={h.locale} href={h.url} />
{/each}
{#if seoToJsonLd(data.seo)}
{@html `<script type="application/ld+json">${seoToJsonLd(data.seo)}</script>`}
{/if}
</svelte:head>
<article>
<h1>{data.post.title}</h1>
{@html data.post.data.body}
</article>Next.js (App Router)
// app/blog/[slug]/page.tsx
import { ContentForge, seoToJsonLd } from '@contentforge/sdk'
const cms = new ContentForge({
url: process.env.CMS_URL!,
apiKey: process.env.CMS_API_KEY!,
})
export async function generateMetadata({ params }) {
const seo = await cms.entries('blog-post').seo(params.slug, { locale: 'en' })
return {
title: seo.meta_title,
description: seo.meta_description,
alternates: {
canonical: seo.canonical_url,
languages: Object.fromEntries(
seo.hreflang.map((h) => [h.locale, h.url])
),
},
openGraph: {
title: seo.og_title,
description: seo.og_description,
images: seo.og_image ? [seo.og_image] : [],
},
}
}
export default async function BlogPost({ params }) {
const post = await cms.entries('blog-post').getBySlug(params.slug)
const seo = await cms.entries('blog-post').seo(params.slug)
const jsonLd = seoToJsonLd(seo)
return (
<>
{jsonLd && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: jsonLd }}
/>
)}
<article dangerouslySetInnerHTML={{ __html: post.data.body }} />
</>
)
}Astro
---
// src/pages/blog/[slug].astro
import { ContentForge, seoToMetaTags } from '@contentforge/sdk'
const cms = new ContentForge({
url: import.meta.env.CMS_URL,
apiKey: import.meta.env.CMS_API_KEY,
})
const { slug } = Astro.params
const post = await cms.entries('blog-post').getBySlug(slug!, { locale: 'en' })
const seo = await cms.entries('blog-post').seo(slug!, { locale: 'en' })
const metaTags = seoToMetaTags(seo)
---
<html>
<head>
{metaTags.map((tag) => {
if (tag.tag === 'title') return <title>{tag.content}</title>
if (tag.tag === 'link') return <link rel={tag.rel} href={tag.href} hreflang={tag.hreflang} />
return <meta name={tag.name} property={tag.property} content={tag.content} />
})}
</head>
<body>
<article set:html={post.data.body} />
</body>
</html>SEO Helpers
import { seoToMetaTags, seoToJsonLd, seoToHtml } from '@contentforge/sdk'
// Get structured meta tag objects (framework-agnostic)
const tags = seoToMetaTags(seo)
// [{ tag: 'title', content: '...' }, { tag: 'meta', name: 'description', content: '...' }, ...]
// Get JSON-LD string for structured data
const jsonLd = seoToJsonLd(seo)
// '{"@context":"https://schema.org","@type":"Article",...}'
// Get a complete HTML string for <head> injection
const headHtml = seoToHtml(seo)
// '<title>...</title>\n<meta name="description" content="..." />\n...'Media Helpers
// Get full URL for a media asset
const url = cms.media.getURL(post.data.cover)
// Get a specific variant
const thumb = cms.media.getURL(post.data.cover, { variant: 'thumb' })
// Build responsive srcset
const srcset = cms.media.getSrcSet(post.data.cover, {
thumb: '400w',
md: '800w',
lg: '1200w',
})
// Get blurhash placeholder
const blurhash = cms.media.getBlurhash(post.data.cover)Locale Detection
import { LocaleClient } from '@contentforge/sdk'
// Detect best locale from Accept-Language header
const locale = LocaleClient.detect(
request.headers.get('accept-language') ?? '',
['en', 'ru', 'de'],
'en', // fallback
)
// List project locales
const locales = await cms.locales.list()Caching
The SDK includes an in-memory cache with stale-while-revalidate semantics:
const cms = new ContentForge({
url: 'https://cms.yourdomain.com',
apiKey: 'pk_live_xxx',
cache: {
maxAge: 60, // Serve fresh data for 60 seconds
staleWhileRevalidate: 300, // Serve stale for up to 5 minutes while refreshing
maxEntries: 100, // Maximum cached entries
},
})
// Disable caching
const cms = new ContentForge({
url: '...',
apiKey: '...',
cache: false,
})
// Invalidate cache (e.g. on CMS webhook)
cms.clearCache()Type Generation (Codegen)
Generate TypeScript types from your content type schemas:
npx contentforge codegen \
--url https://cms.yourdomain.com \
--apiKey pk_live_xxx \
--output src/lib/cms-types.tsAdd to your package.json:
{
"scripts": {
"cms:types": "contentforge codegen --url $CMS_URL --apiKey $CMS_API_KEY --output src/lib/cms-types.ts"
}
}Use the generated types:
import type { BlogPost } from '$lib/cms-types'
const posts = await cms.entries<BlogPost>('blog-post').list({ locale: 'en' })
// posts.data[0].data.title → string (autocomplete works)
// posts.data[0].data.category → 'tech' | 'design' | 'business'Error Handling
import {
ContentForgeError,
NotFoundError,
UnauthorizedError,
} from '@contentforge/sdk'
try {
const post = await cms.entries('blog-post').getBySlug('missing')
} catch (error) {
if (error instanceof NotFoundError) {
// Handle 404
} else if (error instanceof UnauthorizedError) {
// Handle 401 — check your API key
} else if (error instanceof ContentForgeError) {
console.error(`API error ${error.status}: ${error.message}`)
}
}
// Or use the global error handler
const cms = new ContentForge({
url: '...',
apiKey: '...',
onError: (error) => {
console.error(`ContentForge error: ${error.code} — ${error.message}`)
},
})SSR / Custom Fetch
Pass a custom fetch function for SSR environments:
// Node.js / Edge runtime
const cms = new ContentForge({
url: '...',
apiKey: '...',
fetch: customFetch, // e.g. undici fetch, or SvelteKit's event.fetch
})API Reference
ContentForge
| Method | Description |
|--------|-------------|
| entries<T>(type) | Create a typed entry client for a content type |
| seo(type, slug, opts?) | Get SEO metadata for an entry |
| navigation(opts?) | Get navigation/menu items |
| sitemap(opts?) | Get sitemap data for static generation |
| entryLocales(type, slug) | Get available locales for an entry |
| clearCache() | Invalidate the in-memory cache |
EntryClient<T>
| Method | Description |
|--------|-------------|
| list(opts?) | List entries with filtering, sorting, pagination |
| getBySlug(slug, opts?) | Get a single entry by slug |
| getById(id, opts?) | Get a single entry by ID |
| seo(slug, opts?) | Get SEO metadata for an entry |
| locales(slug) | Get available locales for an entry |
MediaClient
| Method | Description |
|--------|-------------|
| getURL(asset, opts?) | Resolve full URL for a media asset |
| getVariants(asset) | Get all variant URLs |
| getSrcSet(asset, widthMap) | Build responsive srcset string |
| getBlurhash(asset) | Get blurhash placeholder string |
LocaleClient
| Method | Description |
|--------|-------------|
| list() | Get all project locales |
| getDefault() | Get the default locale |
| detect(header, supported, fallback?) | Detect locale from Accept-Language (static) |
License
MIT
