@cosmicjs/sdk
Version:
The official client module for Cosmic. This module helps you easily add dynamic content to your website or application using the Cosmic headless CMS.
201 lines (153 loc) • 8.54 kB
Markdown
# RFC: Rich-text blocks - rendering model, blocks endpoint, and `@cosmicjs/rich-text`
- Status: Approved - rolling out (GA)
- Author: Cosmic engineering
- Affected repos: `cosmic-api`, `cosmic-backend`, `cosmic-dashboard`, `cosmic-sdk-js`
- Related: `rich-text` metafield, Custom Blocks (bucket settings `content_blocks`)
## Summary
The `rich-text` metafield stores markdown prose interleaved with `{{shortcode}}`
block tokens. Each token references a reusable **block** defined per bucket in
**Settings -> Custom Blocks** (`settings.content_blocks`).
This RFC proposes a **control-first rendering model**:
1. The API serves `rich-text` values **verbatim**. It never expands tokens or
attaches generated HTML to responses.
2. A dedicated, cacheable endpoint exposes block definitions:
`GET buckets/{slug}/blocks`.
3. Consumers resolve tokens themselves, either with the official renderer
(`@cosmicjs/rich-text`) or their own code.
**Decision (GA rollout):** all three are shipping. `@cosmicjs/rich-text` is being
published to npm as the official renderer (option A below), `publishConfig.access`
is set to `public`, and the docs reference it as the recommended path. The
remaining publish step (`npm publish` / changeset release) runs through the
normal release pipeline with npm credentials.
## Background
An earlier iteration prototyped **server-side expansion** (never released):
requesting an object with `?expand=blocks` added an `expanded_html` field next to
each `rich-text` metafield, containing sanitized `data-block` HTML rendered on
the server.
Problems with that approach:
- **Limited control.** Consumers want React-level (or framework-level) control
over how each block renders. A server-rendered HTML blob forces
`dangerouslySetInnerHTML` and discards component boundaries.
- **Duplicated, drift-prone logic.** The markdown renderer + block registry had
to be reimplemented in `cosmic-api` and `cosmic-backend` as dependency-free JS,
mirroring the dashboard/SDK implementation. Three copies, three chances to
drift.
- **Caching ambiguity.** Expanded HTML is a function of both the object value and
the bucket's block definitions, so object caches must also invalidate on block
edits. Coupling them on the object response makes invalidation harder.
- **Payload bloat.** Every rich-text field carried a second, larger HTML copy.
## Proposal
### 1. Serve values verbatim (remove `expanded_html`)
Remove the `?expand=blocks` / `expanded_html` plumbing from object get/list on
both v2 (`cosmic-api/packages/api`) and v3 (`cosmic-api/packages/api-v3`), and
delete the server-side richText expansion service in `cosmic-api` and
`cosmic-backend`. Stored values are returned unchanged.
### 2. Dedicated blocks endpoint
```
GET buckets/{slug}/blocks -> 200 { "blocks": [ ...BlockDefinition ] }
```
- **Auth:** read key (same as object reads).
- **Source:** `bucket.settings.content_blocks` (returns `[]` if unset).
- **Caching:** Fastly-cached on the **bucket surrogate key**, so editing blocks
invalidates the blocks response without touching object caches. Clients fetch
it once and reuse it across many objects.
- **Versions:** available on both v2 and v3 for parity.
`BlockDefinition`:
```ts
interface BlockDefinition {
name: string; // shortcode slug, e.g. "callout"
title?: string;
description?: string;
content: string; // markdown, plain text, or raw HTML
editor: 'rich-text' | 'plain' | 'html';
}
```
> The same definitions remain available on the bucket object at
> `settings.content_blocks`. The dedicated endpoint exists so renderers can
> fetch just the blocks, with their own cache lifetime, instead of pulling the
> whole bucket.
### 3. Client-side rendering
Consumers resolve `{{shortcode}}` tokens against the fetched definitions:
- **Official renderer** `@cosmicjs/rich-text`: `<RichText value blocks={blocks} />`
or `renderRichText(value, { blocks })`, with an overridable component per block
(`components={{ callout: Callout }}`).
- **Bring your own**: the shortcode grammar + `data-block` HTML contract are
documented; the parser is small and stable.
`data-block` contract (what the default renderer emits per block):
```html
<div class="cosmic-block cosmic-block-<name>" data-block="<name>">
<!-- block content rendered to HTML (markdown or escaped plain text) -->
</div>
```
### 4. Inline object embeds (additive primitive)
Blocks are reusable, bucket-defined snippets. A distinct, additive primitive
lets authors reference a **live Cosmic Object** inline. This is not a block: the
content lives in the referenced object, not in bucket settings.
Canonical token: a reserved, self-closing `object` shortcode.
```
{{object type="posts" id="64f0...abc" slug="hello-world" /}}
```
- `id` is authoritative; `type` drives renderer dispatch and editor preview;
`slug` aids readability/resilience.
- `object` (and `objects`) are reserved block names so a block can't shadow the
token.
- Requires no parser changes (attrs already round-trip) and no server-side
expansion: values are still served verbatim and the `GET /blocks` endpoint is
unchanged.
Rendering stays control-first. `renderRichText` gains two options:
- `objects?: Record<string, React.ComponentType<ObjectBlockProps>>` keyed by
object type slug.
- `resolveObject?: (ref: { id; type?; slug? }) => ResolvedObject | undefined` so
the consumer (who already fetched the parent object, optionally with
relationship depth) supplies resolution.
`ObjectBlockProps` is `{ id, type?, slug?, object? }`. When no component is
registered for a type, a `DefaultObjectBlock` placeholder renders (it never
silently drops the reference). Dashboard read-only/preview views render the same
kind of reference placeholder.
## `@cosmicjs/rich-text` (publish decision)
The package lives at `cosmic-sdk-js/packages/rich-text`, builds, and exposes
`RichText`, `renderRichText`, `BlockProps`, and `BlockDefinition`.
Options considered:
- **A. Publish as a standalone package (chosen).** Gives customers a first-class
renderer immediately and matches the docs. We own support/versioning of a new
public package.
- **B. Hold; ship docs + endpoint only.** Customers render with their own code
against the documented grammar and the blocks endpoint.
- **C. Fold into `@cosmicjs/sdk`** as a subpath export (e.g.
`@cosmicjs/sdk/blocks`).
**Decision: A.** For GA we publish the standalone package so adoption isn't
gated on customers hand-writing a parser. The endpoint + grammar remain the
stable integration surface, so a future move to (C) would be a re-export, not a
breaking change.
### Publish mechanics
- `publishConfig.access` is `public` (scoped package). Initial version `0.1.0`.
- Semver from `0.x`; treat the component `components` map and `BlockProps` as the
primary stability surface.
- `react` stays a peer dependency; ships ESM + CJS + types.
- Release runs through the normal pipeline (build + `npm publish` / changeset)
with npm credentials; not something done from a sandbox.
- Follow-up: pin the shortcode grammar as the cross-package source of truth
(dashboard + SDK must stay byte-for-byte compatible; add a shared fixture
test).
## Alternatives considered
- **Keep server-side expansion (status quo before this RFC).** Rejected for the
control, drift, caching, and payload reasons above.
- **Expansion as an opt-in, separate field on a separate endpoint.** Still
duplicates rendering logic server-side; client rendering already covers the
"I want HTML" case via the renderer.
## Migration / compatibility
- **Not a breaking change.** `?expand=blocks` / `expanded_html` never shipped to
production, so there are no external consumers to migrate and no deprecation
window is needed. It is simply removed.
- No change to how values are stored. Existing `rich-text` content is unaffected.
- Dashboard editor is unaffected (it reads blocks from bucket settings directly).
## Resolved
- **Publish `@cosmicjs/rich-text`?** Yes - publish standalone for GA (option A). A
later fold into `@cosmicjs/sdk` (C) stays open as a non-breaking re-export.
## Open questions
1. Should the blocks endpoint support filtering (e.g. `?name=callout`) or is the
full list sufficient? (Lean: full list; it's small and cacheable.)
2. Do we want a non-React renderer (vanilla/string) in the same package for
server components / other frameworks?
3. Should we add a shared grammar conformance test across dashboard + SDK to
prevent drift?