@mapled/astro
v0.1.1
Published
Read published Mapled content in an Astro site — a server-side client with a tag cache, the publish webhook (purge or deploy hook), preview and live updates.
Maintainers
Readme
@mapled/astro
Read published Mapled content in an Astro site — a server-side client with a tag cache, a publish webhook that purges it (or rebuilds a static site), preview of drafts, and live updates for the tabs that are already open.
Mapled is a hosted headless CMS built for sites created with AI. Your agent (or you) models collections and fills in content; this package is how an Astro site reads what's published. It is @mapled/client with Astro's idioms around it: the same reads, the same types from mapled types generate.
- Install
- Read published content • Rich text and images • Refresh on publish • Preview drafts • Live updates • Live data • API
Install
npm install @mapled/astroGrab the delivery key in Mapled: project settings (the gear in the project's header) → Integrations → Your site, and put it in your site's env. The adapter reads it on the server, so no PUBLIC_ prefix is needed (the delivery key is the one key that may be public; the webhook secret and a server key never are):
MAPLED_KEY=mk_live_…
MAPLED_WEBHOOK_SECRET=whsec_…Two places hold a site's env, and the adapter looks in both. A .env file (and astro dev) fills the site's own import.meta.env, when the site is built — the snippets below hand those values over. A host's environment variables are the server's process.env, which the adapter reads itself when a request arrives — so a site built without a value still finds it where it runs.
Pages that read Mapled render on demand: output: "server" in astro.config.mjs (with an adapter), or export const prerender = false on those pages. A static build works too — the webhook then asks your host to rebuild (Refresh on publish).
Read published content
One client for the whole site, typed by the schema npx @mapled/cli types generate writes:
// src/lib/mapled.ts
import { mapled } from "@mapled/astro";
import type { MapledSchema } from "../mapled-types";
/** Pass `Astro` (or an endpoint's context) so the request's preview cookie is seen; without it, published content only. */
export const cms = (from?: Request | { request: Request }) => mapled<MapledSchema>(from, { key: import.meta.env.MAPLED_KEY });mapled() takes the key it is handed — the one of a .env file, named as import.meta.env.MAPLED_KEY so the build keeps it — else MAPLED_KEY of the server's environment (apiUrl and MAPLED_API_URL likewise). In a page's frontmatter:
---
// src/pages/index.astro
import { cms } from "../lib/mapled";
const home = await cms(Astro).getSingle("homepage");
const { records: posts } = await cms(Astro).getRecords("posts", { sort: "-date", limit: 10 });
---
<h1>{home?.data.headline}</h1>
<ul>{posts.map((p) => <li><a href={`/blog/${p.data.slug}`}>{p.data.title}</a></li>)}</ul>---
// src/pages/blog/[slug].astro
export const prerender = false;
import { cms } from "../../lib/mapled";
const post = await cms(Astro).getRecordBySlug("posts", Astro.params.slug!);
if (!post) return Astro.redirect("/404");
---
<article><h1>{post.data.title}</h1></article>The reads are those of @mapled/client: getRecords(collection, { filter, sort, limit, offset, fields, expand, search, locale }), getRecord, getRecordBySlug, getSingle, getRelease, isPreview. Each published answer is kept in the adapter's cache — in the server process, under the tags mapled and mapled:<collection> — for an hour, or for as long as you say:
// src/lib/reads.ts
import { cms } from "./mapled";
export async function homepage() {
// kept until the next publish purges it; `revalidate: 60` keeps it a minute, `revalidate: 0` never
return cms().getSingle("homepage", { revalidate: false, tags: ["layout"] });
}A page and its layout asking for the same record during one render make one request. A prerendered page reads published content: mapled(Astro) leaves its request unread (Astro 5+ marks it isPrerendered), so a static build stays quiet. The cache is this process's: a long-running server (the Node adapter) holds one, serverless functions hold one each — keep revalidate short there, or build statically. createClient({ key, apiUrl, draft, cache: false }) makes a client by hand, with the cache off when you say so.
Rich text and images
rich_text values are Markdown — render them with a Markdown component, never as raw HTML. Image values are asset ids: assetUrl(id, { width, format }) gives the URL of a resized variant.
// src/lib/images.ts
import { assetUrl } from "@mapled/astro";
export const cover = (id: string) => assetUrl(id, { width: 1280, format: "webp" });Refresh on publish
Mount the webhook and point the project at it (Integrations → Your site → Revalidation webhook, or let your agent call configure_revalidation). Every publish is signed with the project's secret; the handler purges the cache, so the next render reads the new release:
// src/pages/api/mapled/revalidate.ts
import { createRevalidateHandler } from "@mapled/astro/server";
export const prerender = false;
export const POST = createRevalidateHandler({ secret: import.meta.env.MAPLED_WEBHOOK_SECRET });import.meta.env holds the secret of a .env file; on a site built without it the handler reads MAPLED_WEBHOOK_SECRET from the server's environment when the first webhook arrives. Until either has it, it answers 503 and Mapled retries.
A statically built site can't re-render on its own: set MAPLED_DEPLOY_HOOK_URL to your host's build hook (Vercel, Netlify, Cloudflare Pages…) and the same handler asks it for a build. The route itself still runs on a server — output: "static" with an adapter prerenders the pages and keeps the routes marked prerender = false as functions; a host with no functions at all has nothing to take the webhook. The handler calls the hook once per publish — a retry of the same publish doesn't build twice, and a hook that refuses fails the delivery so Mapled retries. The URL is read from the environment on the first webhook and never written anywhere. A handler of your own can verify a request with verifySignature(secret, body, header), drop what it needs with purge(["mapled:posts"]), and answer with the header x-mapled-cache: cacheMark() so live updates follow it.
Preview drafts
Mapled opens the site through a one-time link to the preview route, which trades it for a draft session, keeps the session in the mapled_draft cookie (HttpOnly, Secure, SameSite=Lax) and redirects into the site. From then on mapled(Astro) reads drafts for that visitor, uncached, until the exit route clears the cookie — published content for everyone else.
// src/pages/api/mapled/preview.ts
import { createPreviewHandler } from "@mapled/astro/server";
export const prerender = false;
export const GET = createPreviewHandler();(It trades the link at MAPLED_API_URL when the site's env has one — the API the reads go to.)
// src/pages/api/mapled/preview/exit.ts
import { createExitPreviewHandler } from "@mapled/astro/server";
export const prerender = false;
export const GET = createExitPreviewHandler();A draft page must never be cached by your host or a CDN. The middleware marks it private, no-store while the cookie is there:
// src/middleware.ts
export { onRequest } from "@mapled/astro/middleware";(Next to middleware of your own: sequence(onRequest, yours).) draftOf(request) answers the draft session of a request, or null — what mapled(Astro) uses; pass it as draft to createClient when you build the client yourself. Preview links point at /api/mapled/preview by default; both routes redirect only to a path of the site itself.
Live updates
Open tabs can follow Publish. Mount the release route and render the component once, in the layout — it asks the route every few seconds and reloads the page when a publish lands:
// src/pages/api/mapled/release.ts
import { createReleaseHandler } from "@mapled/astro/server";
export const prerender = false;
export const GET = createReleaseHandler();---
// src/layouts/Layout.astro
import MapledLive from "@mapled/astro/MapledLive.astro";
---
<html><body><slot /><MapledLive /></body></html>The route names the release this server last acknowledged through the webhook — never one its cache hasn't been purged for — so a tab that reloads on it renders the new content, not the old one again. It keeps the delivery key on the server and never asks Mapled. A site without a server (a static build) passes the delivery key instead, <MapledLive key={import.meta.env.PUBLIC_MAPLED_KEY} /> — the one key that is safe in a page; a PUBLIC_ variable for it, never for a secret — and the tab asks Mapled directly, once a minute. From a script of your own: startLive({ endpoint }) (or { key }) from @mapled/astro/live reloads on a publish and returns a stop function; mountLive() is what the component's script calls; watchRelease is the client's own, for anything else.
Live data
Operational collections — content the site's backend writes, which takes effect without a publish — are createAppClient({ serverKey }) from the same package, server-side only. Its calls, Site Identity and the data of signed-in people are documented in @mapled/client.
// src/pages/api/orders.ts
import { createAppClient } from "@mapled/astro";
export const prerender = false;
// the .env file's value, else the server's own environment — a build made without the key still finds it where it runs
const db = createAppClient({ serverKey: import.meta.env.MAPLED_SERVER_KEY ?? process.env.MAPLED_SERVER_KEY! });
export async function POST({ request }: { request: Request }) {
const body = (await request.json()) as { email: string };
const order = await db.create("orders", { email: body.email });
return Response.json({ id: order.id }, { status: 201 });
}API
| Import | What it is |
| --- | --- |
| mapled(from?, { key, apiUrl }?) from @mapled/astro | The site's client from the environment; with Astro, a request or an endpoint's context it reads drafts under the preview cookie |
| createClient({ key, apiUrl, release, locale, draft, fetch, cache }) | A client by hand |
| draftOf(from) | The draft session a request carries, or null |
| assetUrl, createAppClient, MapledError, watchRelease | Re-exported from @mapled/client |
| createRevalidateHandler({ secret, tags, deployHook, fetch }) from @mapled/astro/server | The publish webhook: purges the cache, calls the deploy hook, answers with x-mapled-cache; without a secret, MAPLED_WEBHOOK_SECRET of the server |
| createPreviewHandler({ apiUrl, appUrl }), createExitPreviewHandler() | Into preview and out of it |
| createReleaseHandler({ ttl }) | The release route open tabs ask |
| verifySignature, purge, cacheMark | For a webhook handler of your own |
| onRequest from @mapled/astro/middleware | A draft page answers private, no-store |
| startLive, mountLive from @mapled/astro/live; MapledLive.astro | Open tabs follow Publish |
Errors are MapledError with the API's status and code. Every handler takes a Request or Astro's context ({ request }) and returns a Response, so it is an Astro endpoint as it is.
