Steeren/Rich components, media & widgetslive from the platform← site
Create content

Rich components, media & widgets

A Nukipa post body is Markdown with embedded markers. Most of the text is plain Markdown, but wherever you want something richer than a paragraph — a callout, a table, a chart, a generated image, an interactive widget — the body carries a marker like {{component:UUID}}, and the actual content lives in a separate cms.post_components row. The renderer expands each marker in place when the page is served.

This article covers the component types a body supports, how generated images and widgets work, and the asset library that backs them. One thing to flag up front: there is more than one renderer. The canonical renderer (@nukipa/post-content, packages/post-content/src/components.js) is what serves your live site; the dashboard has its own preview renderer; and the generated Next.js site adds a thin client-side hydration layer of its own. A few behaviours differ between them, and this article calls those out where they bite.

[!NOTE] Components are stored as rows, not as inline HTML. Each row has a component_type and a free-form content JSON object whose shape depends on that type. The source of truth for what keys each type reads on the live site is packages/post-content/src/components.js. If a key isn't read there, it doesn't render.

How a component gets into a post

Two ways:

  1. The writer emits it. When cms.generate-blog-post (or a batch run) writes a post, it can emit {{component:TYPE}} … {{/component}} blocks. The worker validates each block against the component registry (services/cms/src/lib/componentTypes.js), replaces it with a {{component:UUID}} marker, and inserts the row. A block that fails validation is dropped before it reaches the database.
  2. You add it directly. cms_add_component (or POST /posts/:id/components with {component_type, content, position_hint}) inserts a component on an existing draft. The dashboard's WYSIWYG editor does the same under the hood.

After editing, components only go live when you publish — publish snapshots the live components into post_versions.components_snapshot, and public reads serve that snapshot, not the live draft.

The component types

Every type below renders to static HTML unless marked as an island — an island is a placeholder the host page hydrates client-side (for click tracking, form submission, charts, carousels, widgets). Markdown is supported inside the fields marked "(markdown)".

component_type What it's for Key content fields
callout A boxed note/warning with a fixed icon level (note|tip|important|warning|caution), content (markdown)
faq Expandable Q&A accordion (<details>) items: [{question, answer (markdown)}]
steps Numbered vertical steps with a connector line items: [{title, description (inline markdown)}]
card A titled card, optional image on top title, content (markdown), optional image_url
chart A data chart, hydrated with Chart.js — island chart_type, data, options, optional title
data_table A plain <table> columns (string array or {key,label}[]), rows (object or array form), optional title
comparison A table with non-first columns emphasised (us-vs-them style) columns: string[], rows: any[][]
process Horizontal stages joined by arrows items: [{title, description?, icon?}]
image_carousel A swipeable image gallery (prev/next) — island items: [{image_url, alt?, caption?}]
image A single inline image (figure + caption + credit) asset_id?, url?, alt, caption? (markdown), attribution?. Can be generated — see below
map A pin/choropleth map map_type, center, zoom, markers, region_set, region_data, color_scale. See the warning below — not rendered on the live site.
widget A self-contained interactive HTML document in an iframe — island prompt, optional csv_data, plus worker-filled html_content, status. Generated — see below
cta A call-to-action button (or inline contact form) wired to your CTA analytics / CRM — island cta_id or cta_key (authoring inputs), optional label_override, description_override
contact_form An inline contact form that lands in your CRM — island optional title, description, submit_label, success_message, fields: [{name,label,type,required}]

A few details worth knowing:

  • chart emits a <canvas> plus a data-chart JSON payload ({type, data, options}); the host page lazy-loads Chart.js and draws it. The canonical renderer passes chart_type through verbatim into the payload — no aliasing. The donutdoughnut rename happens later, in the site's hydration layer (PostIslands.tsx), not in the renderer. data and options go to Chart.js untouched, so anything Chart.js natively accepts works.
  • data_table is forgiving about row shape — columns can be plain strings or {key,label} objects, and rows can be arrays (positional) or objects (keyed by column). Note the writer registry tells the model to author rows as "an array of objects matching column keys"; the array-form tolerance is renderer leniency, not the documented authoring shape.
  • comparison expects string columns and array rows. If the writer accidentally hands it the data_table object shape (column objects + row objects keyed by column key), the renderer detects that and projects it back into the canonical form rather than printing [object Object].
  • process items can carry an icon (a Material Icons name like factory or bolt); without one, the stage shows its number.
  • callout icons and labels are fixed per level (note→info/"Note", tip→lightbulb/"Tip", important→star/"Important", warning→warning/"Warning", caution→block/"Caution"). You pick the level; you don't pick the icon.

[!WARNING] map is a registered type with no renderer on the live site. It passes registry validation, so the writer can legitimately emit a map block, and it shows up in the dashboard. But the canonical renderer has no map entry in its RENDERERS table — so on the public site it renders to empty HTML and the marker drops out. Treat map as not-yet-shipped on the live surface; don't author one expecting it to draw.

[!NOTE] A few types live in the renderer but aren't part of the day-to-day authoring registry: quote (a <blockquote> from content (markdown) + optional cite), form (a lead-gen form referenced by form_slug), and referenced_blog_post (an email-safe "see also" card, mainly used by newsletters). The cta and contact_form types above are the usual way to put a conversion point in a post.

Unknown — and unrendered — types drop to nothing

If a component_type has no renderer in the canonical RENDERERS table, the renderer returns empty HTML and the marker drops out — the surrounding Markdown stays clean. This is deliberate: it never crashes the page. But "no renderer" covers two cases, not one:

  1. A typo or a deprecated type — degrades to a gap, as you'd expect.
  2. A currently-registered type that the canonical renderer doesn't implement — today that's map. It validates fine, then renders to nothing.

So "it passed validation" does not guarantee "it will show up on the live site."

Images: cover and inline

There are two image surfaces on a post:

  • The cover (hero) — cms.posts.cover_asset_id.
  • Inline imagesimage components in the body.

Both can be filled three ways: pick an existing asset from the library, upload one, or generate one. Generation is the same pipeline for both, driven by the cms.generate-image worker.

How generation works

When you ask for a generated image — cms_generate_cover for the hero, cms_regenerate_component_image for an inline image, or by emitting an image block with a prompt and no asset_id/url (which auto-enqueues the worker) — the worker runs a two-call pipeline:

  1. Prompt-craft. A cheap text model (gpt-4.1-mini by default) turns your brief plus the tenant's brand colours, image style, and the image's intent into a structured visual spec.
  2. Render. An image model (gpt-image-2 by default) renders that spec. The result is uploaded to Supabase Storage, recorded as a cms.assets row, and wired back into the post (cover_asset_id for a cover, or the component's asset_id/url for an inline image).

Both models are env-overridable (CMS_IMAGE_PROMPT_MODEL, CMS_IMAGE_MODEL), so the defaults above can change without a redeploy.

You steer generation with two parameters:

Parameter Values Effect
intent hero, inline, illustration, diagram Shapes the prompt-craft spec and picks the quality tier. hero also pulls in the post's title/excerpt for context.
aspect 16:9, 4:3, 1:1, 3:2 Output dimensions. Defaults to 16:9.

[!NOTE] Quality is tied to intent, not chosen directly: hero renders at the high tier, everything else at the medium tier. Both tiers are env-overridable (CMS_IMAGE_QUALITY_HERO, CMS_IMAGE_QUALITY_INLINE), and the underlying image call also accepts auto (let the model pick). Output is JPEG by default (CMS_IMAGE_OUTPUT_FORMAT) for smaller files; PNG is available via env for lossless/diagram cases.

The inline-image lifecycle

An inline image component carries a status so the UI (and the public renderer) can show progress without polling the worker:

status What the renderer shows
pending / generating A "Generating image…" placeholder
ready The image (figure + caption + credit)
error Nothing — the component renders empty, and error_message is set on the row

A manually-curated image — one where you picked an asset from the library — carries asset_id + url from the start and skips the worker entirely. The cover has no per-row status field; if a cover generation fails, the UI reads the failure from the job row in jobs.jobs instead.

Captions and attribution

An image component renders a <figcaption> from caption (markdown) and a credit line from attribution:

{
  "url": "https://…/storage/v1/object/public/cms-assets/…/photo.jpg",
  "alt": "Industrial control panel on a factory floor",
  "caption": "OPC UA bridges the IT/OT gap.",
  "attribution": { "author": "Jane Doe", "source": "Unsplash", "source_url": "https://unsplash.com/…" }
}

The credit joins author and source and renders as Photo: Jane Doe / Unsplash. When source_url is a valid http(s) URL the credit links to it, with rel="noopener nofollow". This matters because Nukipa can be configured to source covers from Unsplash instead of generating them (a per-tenant image_provider setting): when it does, it stores the photographer + Unsplash link in the asset metadata as attribution, which Unsplash's terms require, and it fires the required download-analytics ping. The Unsplash path soft-falls-through to OpenAI generation if no relevant photo turns up (or the API key is unset).

[!TIP] Regenerating a cover makes a best-effort attempt to flush the public site's cache for the article and the index page, so the new cover shows up on the next request. The flush is fire-and-forget — if it fails, or the post has no slug, nothing breaks: the new cover just lands on the page's normal ISR revalidate window instead (typically ~60s for blog detail pages).

Widgets: generated interactive HTML

A widget is a small, self-contained interactive thing — a calculator, a comparison toggle, a custom chart — generated as a complete HTML document (its own <style>/<script>) and embedded in an iframe via srcdoc. You don't author the HTML; you brief it and a worker writes it.

You describe what you want in content.prompt (and optionally paste csv_data for the widget to work with). The cms.generate-widget worker calls Claude Sonnet, strips any stray Markdown fences or trailing prose, validates that the output is a real HTML document, caps it, and writes it to content.html_content.

[!NOTE] The widget-generation call has a web_search tool enabled (max_uses: 2), so the model may run up to two web searches while building the widget. That's generation-time only — the finished widget is still a static document with no live data access at runtime.

It carries the same lifecycle status as inline images, but the placeholder text is distinct per state:

status Placeholder shown
pending "Widget queued for generation..."
generating "Widget generating..."
ready The widget iframe
error "Widget generation failed"

The worker settles status='error' for a few concrete reasons: the model's output has no <!DOCTYPE html>, the document is missing its closing </html> tag, or the HTML exceeds the 500 KB cap. In each case error_message is set on the row.

Generating and editing a widget

  • Generate / regenerate: cms_regenerate_widget (or POST /posts/:id/components/:componentId/regenerate). With no feedback, it generates from the prompt.
  • Edit: pass feedback (the worker's edit_feedback). The worker treats it as an edit pass — it hands Sonnet the current html_content plus your feedback and asks for a revised document. The last five edits are kept as edit_history so successive tweaks build on each other.

If the widget belongs to an already-published post, the worker patches the published snapshot in place when generation settles — so visitors see the finished widget (or the "Widget generation failed" message) without you having to re-publish.

What the generated widget can and can't do

The widget's constraints are conventions the generator is told to follow in its system prompt, plus a CSP <meta> tag baked into the generated document — not a browser sandbox enforced by the live host.

[!WARNING] The widget's system prompt instructs Sonnet that the document runs in a sandbox="allow-scripts" iframe and must therefore avoid storage and external fetch. On the canonical Next.js site that is not the live mechanism: the host injects the document via iframe.srcdoc with no sandbox attribute (it stays same-origin so the host can measure its height and auto-resize). So the limits below are prompt conventions and CSP, not a hard browser cage. Don't rely on a sandbox to block anything; rely on the generator having been told to stay within these rules.

Convention the generator is told to follow Why
No localStorage, sessionStorage, cookies, or any storage API Instructed as unavailable; keep all state in in-memory JS variables
No fetch / XMLHttpRequest to external APIs Instructed as blocked; all data must be hardcoded or computed in-page
Max 800px total height, down to 320px width The host iframe may clip taller content
Report height via postMessage (and the host also re-measures via ResizeObserver) So the frame auto-resizes to fit
Keep output under 30 KB A prompt target only — not enforced by the worker

There is one hard limit, enforced in the worker: persisted html_content is capped at 500 KB (MAX_CONTENT_SIZE). Anything larger throws and settles the widget to error. The 30 KB figure is a soft instruction in the prompt; the worker does not reject at 30 KB.

Allowed (per the prompt + CSP): loading libraries (e.g. Chart.js, D3) from cdn.jsdelivr.net, and Google Fonts. The generator is also told your brand colours and font, and builds a small window.__LANG-keyed translations object so short UI labels follow the post's language when it's translated. Data values stay in the post's primary language.

[!WARNING] Widgets are static once generated — they can't call your API or read live data at runtime. If a brief needs a server (saving input, fetching prices, auth), the widget can't do it. Use a contact_form or cta for anything that has to reach your backend.

The asset library

Every generated and uploaded image lands in cms.assets, the per-tenant library. You can browse it (cms_list_assets), fetch one (cms_get_asset), upload (cms_upload_asset), generate a free-standing image (cms_generate_asset_image), edit alt text (cms_update_asset_alt), or delete (cms_delete_asset).

cms_generate_asset_image is worth calling out: it runs the same prompt-craft + render pipeline as cover/inline generation but isn't bound to any post. It returns the asset (including a public_url) synchronously, so an agent building a custom page can generate an image and use the URL immediately. It takes brief (required), intent (default illustration), aspect (default 16:9), and optional alt / filename overrides.

[!NOTE] "Synchronously" means the call blocks until the image is done — typically 30–120s, capped at ~7 minutes by the LLM client timeouts. It can also fail/throw (e.g. the image model returns no data), so handle the error path; this is a multi-minute blocking call, not an instant one.

Limits

The library is backed by the cms-assets Supabase Storage bucket, which has fixed constraints:

Limit Value
Max file size 20 MB
Allowed types image/png, image/jpeg, image/webp, image/gif, image/svg+xml, application/pdf
Bucket reads Public — every asset URL is directly usable in the browser
List page size Up to 200 per request

Uploads are validated against both the size cap and the mime allowlist before they're stored. Worker-generated images skip the user-facing validation (the bytes come straight from the image model) but land in the same bucket under the same <tenant_id>/<uuid>.<ext> path scheme.

[!NOTE] The bucket accepts PDFs, but the post body has no PDF component — PDFs in the library are there to be linked (e.g. a gated download), not embedded inline.

FAQ

Can I write a component's raw HTML myself? No. You set the content JSON fields; the renderer produces the HTML. The one exception is widget, where the generated document is full HTML — but you don't author it directly, you brief it and the worker writes it.

Why did my component disappear after publishing? Three common causes. (1) Public reads serve the published snapshot, not the live draft — if you added or edited a component and didn't publish, visitors won't see it. (2) Check status — an image in error renders empty, and a widget in error shows "Widget generation failed". (3) The type may be registered but not rendered on the live site (map), or a deleted/archived CTA may have resolved to nothing — see below.

My funnel chart shows nothing on the live site — why? funnel is a valid chart_type in the registry, and the canonical renderer passes it straight through to Chart.js. But Chart.js has no native funnel type, so on the live (Next.js) site it draws nothing. A funnel-as-SVG branch exists only in the dashboard preview, not on the canonical surface. For the live site, stick to chart types Chart.js renders natively (line, bar, donut/doughnut, bubble, mixed).

Why did my CTA vanish from a published post? A cta row only stores cta_id/cta_key (authoring inputs) plus optional overrides. On each public read the server resolves those against context.ctas into a content._resolved object, which is what the renderer actually reads. If the referenced CTA is archived, deleted, or otherwise not found, it resolves to null and the renderer drops the CTA to empty HTML — no error is shown. The draft stays publishable; the CTA just silently disappears. Point it at a live registry entry.

Can a widget save what a visitor types, or fetch live data? No. A generated widget is in-memory and client-side: it's briefed not to use storage or external network calls, and it has no path to your backend. Route anything that needs your backend through a contact_form or cta.

How do I generate an image without attaching it to a post? Use cms_generate_asset_image. It returns the asset and its public_url synchronously (30–120s typical), ready to drop into any page.

Served live from the platform · /docs/components-media-widgets