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_typeand a free-formcontentJSON object whose shape depends on that type. The source of truth for what keys each type reads on the live site ispackages/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:
- 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. - You add it directly.
cms_add_component(orPOST /posts/:id/componentswith{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:
chartemits a<canvas>plus adata-chartJSON payload ({type, data, options}); the host page lazy-loads Chart.js and draws it. The canonical renderer passeschart_typethrough verbatim into the payload — no aliasing. Thedonut→doughnutrename happens later, in the site's hydration layer (PostIslands.tsx), not in the renderer.dataandoptionsgo to Chart.js untouched, so anything Chart.js natively accepts works.data_tableis forgiving about row shape —columnscan be plain strings or{key,label}objects, androwscan 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.comparisonexpects string columns and array rows. If the writer accidentally hands it thedata_tableobject 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].processitems can carry anicon(a Material Icons name likefactoryorbolt); without one, the stage shows its number.callouticons 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]
mapis a registered type with no renderer on the live site. It passes registry validation, so the writer can legitimately emit amapblock, and it shows up in the dashboard. But the canonical renderer has nomapentry in itsRENDERERStable — so on the public site it renders to empty HTML and the marker drops out. Treatmapas 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>fromcontent(markdown) + optionalcite),form(a lead-gen form referenced byform_slug), andreferenced_blog_post(an email-safe "see also" card, mainly used by newsletters). Thectaandcontact_formtypes 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:
- A typo or a deprecated type — degrades to a gap, as you'd expect.
- 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 images —
imagecomponents 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:
- Prompt-craft. A cheap text model (
gpt-4.1-miniby default) turns your brief plus the tenant's brand colours, image style, and the image's intent into a structured visual spec. - Render. An image model (
gpt-image-2by default) renders that spec. The result is uploaded to Supabase Storage, recorded as acms.assetsrow, and wired back into the post (cover_asset_idfor a cover, or the component'sasset_id/urlfor 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:
herorenders 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 acceptsauto(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_searchtool 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(orPOST /posts/:id/components/:componentId/regenerate). With no feedback, it generates from the prompt. - Edit: pass
feedback(the worker'sedit_feedback). The worker treats it as an edit pass — it hands Sonnet the currenthtml_contentplus your feedback and asks for a revised document. The last five edits are kept asedit_historyso 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 externalfetch. On the canonical Next.js site that is not the live mechanism: the host injects the document viaiframe.srcdocwith nosandboxattribute (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_formorctafor 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.