@clearcms/astro
v0.3.0
Published
Astro Content Layer adapter for clear. Wraps @clearcms/sdk so consumers can use Astro's native getCollection() / getEntry() APIs to read from a clear bucket (fs / R2 / S3) or a running admin (REST).
Downloads
59
Readme
@clearcms/astro
Astro Content Layer adapter for clear. Drop clearLoader('posts') into defineCollection, then read with getCollection('posts') / getEntry('posts', slug) like any other Astro content. Same surface against any clear backend — fs / REST / R2 / S3.
Status: v0. Read-only. MIT licensed. Wraps
@clearcms/sdk— that's the seam.
Why
Astro 5+ ships a Content Layer API: any Loader you point at can drive getCollection() and getEntry(). clear stores content as JSON in a bucket. @clearcms/astro is the bridge — it turns a clear collection into an Astro content collection.
The big win: static-build deploys. Set CLEAR_BACKEND=fs (or r2 / s3), run astro build, and you get static HTML rendered straight off bucket files — the admin doesn't need to be reachable at build time, never mind at request time.
Install
pnpm add @clearcms/astro
# or: npm install @clearcms/astroastro@^6 is a peer dependency.
Use
Configure collections
// src/content/config.ts
import { defineCollection } from 'astro:content';
import { clearLoader } from '@clearcms/astro';
export const collections = {
posts: defineCollection({ loader: clearLoader('posts') }),
recipes: defineCollection({ loader: clearLoader('recipes', { locale: 'en' }) }),
};Render pages
---
// src/pages/index.astro
import { getCollection } from 'astro:content';
const posts = await getCollection('posts');
---
<ul>
{posts.map((post) => (
<li>
<a href={`/${post.id}`}>{post.data.title}</a>
<small>{new Date(post.data._meta.publishedAt).toLocaleDateString()}</small>
</li>
))}
</ul>---
// src/pages/[slug].astro
import { getEntry } from 'astro:content';
import { renderTipTap } from '@clearcms/astro';
const post = await getEntry('posts', Astro.params.slug);
if (!post) return new Response('Not found', { status: 404 });
---
<article>
<h1>{post.data.title}</h1>
<div set:html={renderTipTap(post.data.body)} />
</article>Globals
Identity, nav, and theme tokens are singletons — they don't fit the collection-shaped Astro API. Use the plain helpers:
---
import { getIdentity, getNav, getThemeTokens } from '@clearcms/astro';
const identity = await getIdentity();
const nav = await getNav();
const tokens = await getThemeTokens();
---
<title>{identity.siteTitle}</title>These reuse the same env-driven config as clearLoader, so one setup serves both.
Entry shape
Each Astro entry produced by clearLoader has:
| Field | Source |
|---|---|
| id | clear's item.slug |
| data | { ...item.data, _meta: { id, locale, status, publishedAt, scheduledFor, updatedAt, createdAt, authorId, canonicalId, baseId, deletedAt } } |
| digest | item.updatedAt (lets Astro skip re-loads when nothing changed) |
| body / rendered | Not set. clear stores richtext as TipTap JSON, not markdown — there is no raw "body string" to surface. |
So in templates:
post.data.title— your content fields (whatever your collection defines).post.data._meta.publishedAt— clear's protocol-level metadata.
Caveat: if you literally have a field named
_metaon your collection it'll be shadowed by the protocol metadata. Pick a different field slug.
Richtext fields (TipTap JSON)
clear stores kind: 'richtext' fields as TipTap / ProseMirror JSON, not HTML or markdown. The loader passes the JSON through unchanged. To render:
renderTipTap()(recommended): ships in@clearcms/astro. Thin wrapper over@tiptap/htmlconfigured with the sameStarterKitextensions the admin uses, plusImage. Returns''for null/empty input, so it's safe to pipe straight intoset:html.--- import { renderTipTap } from '@clearcms/astro'; --- <article set:html={renderTipTap(post.data.body)} />Covered: paragraph, headings (h1–h6), bullet/ordered lists, blockquote, code (inline + block), horizontal rule, hard break, image, link, bold/italic/underline/strike marks. If you need extra extensions, drop down to
@tiptap/htmldirectly.React / Vue / Svelte islands: use the framework-specific TipTap renderer.
Server-side reshape: if you're on the REST backend you can hit the admin's
/api/v1/{collection}/{slug}?format=htmldirectly —clearLoaderdoesn't expose that today; open an issue if you need it.
Type narrowing
clearLoader does not ship a Zod schema. @clearcms/sdk already validates every read against the protocol; layering a second schema on top would just duplicate it.
If you want extra type narrowing on top of a specific collection, add a schema to defineCollection yourself:
import { defineCollection, z } from 'astro:content';
import { clearLoader } from '@clearcms/astro';
export const collections = {
posts: defineCollection({
loader: clearLoader('posts'),
schema: z.object({
title: z.string(),
subtitle: z.string().optional(),
body: z.unknown(), // TipTap JSON
_meta: z.object({
publishedAt: z.string().nullable(),
updatedAt: z.string(),
// ...whichever fields you actually consume
}).passthrough(),
}),
}),
};Configuration (env)
Both clearLoader and the global helpers resolve their backend in this priority order:
- Explicit
clientopt passed to the call. - Process env (below).
| Var | Backend | Required | Default | Notes |
|---|---|---|---|---|
| CLEAR_BACKEND | * | no | rest | one of fs / rest / r2 / s3 |
| CLEAR_LOCALE | * | no | (sdk default en for .get, all locales for .list) | overrideable per-clearLoader call via { locale } |
| CLEAR_ADMIN_URL | rest | no | http://localhost:3000 | |
| CLEAR_API_TOKEN | rest | no | — | required only for non-published reads |
| CLEAR_BUCKET_ROOT | fs | yes | — | absolute path to a clear bucket (the dir containing collections/, content/, theme/, …) |
| CLEAR_R2_ACCOUNT_ID | r2 | yes | — | |
| CLEAR_R2_BUCKET | r2 | yes | — | |
| CLEAR_R2_ACCESS_KEY_ID | r2 | yes | — | |
| CLEAR_R2_SECRET_ACCESS_KEY | r2 | yes | — | |
| CLEAR_S3_ENDPOINT | s3 | yes | — | full https URL |
| CLEAR_S3_BUCKET | s3 | yes | — | |
| CLEAR_S3_REGION | s3 | yes | — | |
| CLEAR_S3_ACCESS_KEY_ID | s3 | yes | — | |
| CLEAR_S3_SECRET_ACCESS_KEY | s3 | yes | — | |
These are consumer-side env vars — they're independent of the admin's internal CLEAR_STORAGE_ROOT / CLEAR_DB_URL.
Static build deploy
The whole point of this package: build a site to plain HTML against a clear bucket, deploy dist/ anywhere a CDN runs, no admin in the hot path.
# CI / pre-deploy step:
export CLEAR_BACKEND=fs
export CLEAR_BUCKET_ROOT=/var/data/my-project/bucket # synced from the admin
astro build
# Outputs: ./dist/ — plain HTML/JS/CSS. Deploy to Netlify / Cloudflare
# Pages / S3+CloudFront / GitHub Pages / nginx static / wherever.R2 / S3 work the same — just swap the env vars:
export CLEAR_BACKEND=r2
export CLEAR_R2_ACCOUNT_ID=...
export CLEAR_R2_BUCKET=my-clear-bucket
export CLEAR_R2_ACCESS_KEY_ID=...
export CLEAR_R2_SECRET_ACCESS_KEY=...
astro buildIn dev (astro dev) you typically point at a running admin via the REST backend — that's the default.
Multiple buckets / advanced
Pass an explicit client to mix buckets in one Astro project:
import { defineCollection } from 'astro:content';
import { clearLoader } from '@clearcms/astro';
export const collections = {
blog: defineCollection({
loader: clearLoader('posts', {
client: { backend: 'fs', root: '/data/blog/bucket' },
}),
}),
docs: defineCollection({
loader: clearLoader('articles', {
client: { backend: 'rest', adminUrl: 'https://docs.example.com' },
}),
}),
};The same client opt is on getIdentity / getNav / getThemeTokens.
Errors
The loader propagates @clearcms/sdk errors. The most common is ClearSdkError with .code of 'transport' (network failure), 'parse' (a bucket file disagreed with the protocol schema), 'config' (env vars missing), or 'unauthorized' (REST backend, draft read without a token).
getEntry() returns undefined for a missing slug — clearLoader already wiped/repopulated the store, so misses are expressed as Astro misses, not thrown errors.
License
MIT.
