Build for Dunea
Blocks: sections, blocks and app embeds
Ship owner-editable sections in a plugin or theme: block anatomy, settings, presets, embeds, the import allowlist and how blocks are reviewed.
A block is a React component plus a small schema. Owners place blocks on their pages from the Sections panel in Dunea, reorder them, hide them and edit their settings, without code and without asking the AI. Blocks are how a plugin puts something visible on a site (a reviews strip, a booking widget, a size chart) and how a theme ships real, editable sections instead of only design direction.
There are three kinds:
| Kind | What it is | Examples |
|---|---|---|
section | A full-width part of a page. Lives in a section group (a page); owners add, reorder and hide it. | Hero, pricing table, product grid, reviews strip |
block | A smaller piece inside a section that accepts blocks. | A testimonial card, an FAQ question, a "buy" button |
embed | Rendered once per page, outside any section. Owners switch it on or off. | Announcement bar, chat bubble, cookie notice |
Where blocks live in an app
frontend/src/blocks/<ns>/<name>.tsx the component (default export)
frontend/src/blocks/<ns>/<name>.block.json its schema
frontend/src/content/sections.json section groups (pages) → ordered instances + settings
frontend/src/dunea/blocks.tsx the runtime: <SectionGroup id>, <BlockEmbeds/>, helpers<ns> is the namespace:
local: blocks the Dunea AI writes for this app.theme: the applied theme's blocks (written when the theme is applied).- your listing slug: your plugin's blocks, written by Dunea when the plugin is installed and removed when it is uninstalled.
An instance in sections.json references a block by <ns>/<name>, for example acme-reviews/stars. Settings live in the app's repository, so an owner's edits show in the preview straight away and go live with their next publish, exactly like code.
Anatomy of a block
A block in your manifest is its schema plus the component source:
// dunea.plugin.ts
import { definePlugin } from '@dunea/plugin-sdk';
import { readFileSync } from 'node:fs';
export default definePlugin({
id: 'acme-reviews',
// …listing fields…
permissions: ['site:blocks', 'reviews:read'],
blocks: [
{
name: 'stars',
title: 'Star rating strip',
kind: 'section',
description: 'Average rating, count and three recent quotes',
settings: [
{ key: 'heading', label: 'Heading', type: 'single_line_text', default: 'Loved by our customers' },
{ key: 'show_quotes', label: 'Show quotes', type: 'boolean', default: true },
{
key: 'tone', label: 'Background', type: 'select', default: 'surface',
options: [{ value: 'surface', label: 'Card' }, { value: 'accent', label: 'Accent' }],
},
],
pageTypes: ['home', 'product', 'page'],
presets: [{ name: 'With quotes', settings: { show_quotes: true } }],
code: readFileSync('./blocks/stars.tsx', 'utf8'),
},
],
});| Field | Meaning |
|---|---|
name | Handle, lowercase with -. The file name in the app (stars.tsx, stars.block.json). Unique in your listing. |
title, description | What owners see in the "Add section" picker. |
kind | section, block or embed. |
settings | Up to 40 typed fields; the Sections panel renders a form from them. See Settings. |
accepts, maxBlocks | Sections only: which child blocks it takes ('*', a block name in your namespace, or a full <ns>/<name>) and how many. |
pageTypes | Where it makes sense: home, page, product, collection, cart, blog, article, funnel_step, any. The picker filters on it. |
presets | Up to 10 starting points (settings, and child blocks for sections) shown in the picker. |
code | The component's TSX source, up to 60 KB. |
Blocks need the site:blocks permission. Owners see it at install as "Add sections and blocks the owner can place on the site".
The component
// blocks/stars.tsx
import { useBlock, RichText, Link } from '@dunea/blocks';
import { Star } from 'lucide-react';
import clsx from 'clsx';
export default function Stars() {
const { settings } = useBlock();
return (
<section
className={clsx(
'py-[var(--dunea-space-section)] px-[var(--dunea-space-gutter)]',
settings.tone === 'accent' ? 'bg-[var(--dunea-color-accent)]' : 'bg-card',
)}
>
<h2 className="font-heading text-[length:var(--dunea-text-3xl)]">{settings.heading}</h2>
<div className="mt-4 flex gap-1" aria-hidden>
{[0, 1, 2, 3, 4].map((i) => <Star key={i} className="h-5 w-5" />)}
</div>
<Link to="/reviews" className="mt-6 inline-block underline">Read all reviews</Link>
</section>
);
}- Default-export the component. It receives
{ settings, blocks, instance, schema }as props;useBlock()returns the same. settingsalready has your defaults applied. Owners can clear any field, so handle empty values.- Style with the theme's classes and variables (
bg-background,text-foreground,bg-primary,--dunea-*), never hardcoded colours, so your block looks right in every theme. See the Themes guide.
What @dunea/blocks gives you
| Export | Use |
|---|---|
useBlock() | { settings, blocks, instance, schema } of the current block |
<InnerBlocks /> | Inside a section: renders its child blocks in order (hidden ones skipped) |
<Image image={settings.photo} /> | Renders an image setting. Nothing without a safe URL; lazy by default |
<Link to={settings.link}> | Site paths go through the router; https, mailto: and tel: links render safely; anything else renders as text |
<RichText text={settings.body} /> | A rich_text setting as safe markup: paragraphs, headings, lists, quotes, bold, italic, code, links. Never parses HTML |
<Money value={settings.price} />, formatMoney() | A money setting (minor units) in the visitor's format |
useMetafield, useMetafields, useMetaobjects, useMetaobject | Read custom data |
Import allowlist
Block code runs on the owner's site, so it may only import:
react @dunea/blocks lucide-react clsxAnything else fails the check (DUN-B100). There is no network access from block code: read data through the @dunea/blocks hooks, and do anything server-side in your own service through webhooks and the Plugin API.
Settings
Settings use the same typed fields as custom data, so a value that validates in the Sections panel validates everywhere:
| Type | Value | Panel input |
|---|---|---|
single_line_text, multi_line_text | string | text, textarea |
rich_text | string (safe markdown subset) | textarea; render with <RichText> |
integer, decimal | number (validations.min / max) | number |
boolean | true / false | switch |
date, date_time | YYYY-MM-DD, ISO 8601 | date pickers |
url | https://… or /path | text |
email, color | string, #RRGGBB | text, colour |
money | { amount, currency } in minor units | amount + currency |
image | { url, alt? } | URL + alt text |
select | one of options[].value | dropdown |
json | any JSON, up to 16 KB | JSON editor |
product_reference, variant_reference, collection_reference | id | picker from the owner's shop |
metaobject_reference | id (validations.metaobjectType) | picker from that content type |
page_reference | /about | text |
Add list: true for up to 50 values. Every default must itself be valid; check reports one that isn't. Give each setting a sensible default so a freshly added section looks finished.
Presets and child blocks
A preset is what the owner starts from when they add your section:
{
name: 'faq',
title: 'FAQ',
kind: 'section',
accepts: ['faq-item'],
maxBlocks: 20,
settings: [{ key: 'heading', label: 'Heading', type: 'single_line_text', default: 'Questions' }],
presets: [
{
name: 'Three questions',
blocks: [
{ type: 'faq-item', settings: { question: 'Do you ship abroad?', answer: 'Yes, to 40 countries.' } },
{ type: 'faq-item', settings: { question: 'Can I return it?', answer: 'Within 30 days.' } },
{ type: 'faq-item', settings: { question: 'Is it vegan?', answer: 'Every product is.' } },
],
},
],
code: '…',
}The section renders its children with <InnerBlocks />; each child is a kind: 'block' entry in your blocks with its own settings and component.
App embeds
An embed renders once per page, outside the page's sections. Owners switch it on in the Sections panel; its settings live in sections.json under embeds:
{ "embeds": { "acme-chat/bubble": { "enabled": true, "settings": { "greeting": "Hi! Questions?" } } } }Every new Dunea app renders <BlockEmbeds /> once in App.tsx.
sections.json
The owner's layout. You never write it; Dunea, the owner and the Dunea AI do. It helps to know its shape when you design presets:
{
"version": 1,
"groups": {
"home": {
"pageType": "home",
"sections": [
{ "id": "hero", "type": "local/hero", "settings": { "heading": "Fresh bread, every morning" } },
{ "id": "stars", "type": "acme-reviews/stars", "settings": { "show_quotes": false } }
]
}
},
"embeds": {}
}Ids are unique across the file. Dunea validates every save the same way in the panel, in studio and for the AI: known block types of the right kind, accepted children, maxBlocks, and every instance's settings against your fields (defaults applied first). A type the app no longer has renders nothing on the live site and a visible warning in the preview.
Install, update and uninstall
- Install. When the install becomes active, Dunea writes your blocks to
frontend/src/blocks/<your-slug>/in the app. If the app's sandbox is asleep, they are written the next time it wakes (or the owner opens the Sections panel). Nothing is placed on a page automatically: owners add your sections themselves. - Update. When the owner's install moves to a new version, the folder is replaced with that version's blocks. Instances of blocks you removed are dropped from the owner's pages, so keep block names stable.
- Uninstall or suspension. The folder is deleted and every section, child block and embed of yours is removed from the owner's pages.
Before writing, Dunea re-runs the static block checks on every block; a block that fails is never written.
Review rules
Block code is always reviewed by a person, and any change to blocks (or to a theme's presets) takes a version off the fast track. dunea-plugin check runs the static rules locally:
| Id | Rule |
|---|---|
DUN-B100 | Only react, @dunea/blocks, lucide-react and clsx may be imported |
DUN-B101 | No eval or new Function |
DUN-B102 | No dynamic import() |
DUN-B103 | No network access (fetch, XMLHttpRequest, WebSocket, EventSource, sendBeacon) |
DUN-B104 | No raw HTML (dangerouslySetInnerHTML, innerHTML, outerHTML, insertAdjacentHTML); use <RichText> |
DUN-B105 | No cookies or browser storage |
DUN-B106 | No navigating the page yourself; use <Link> |
DUN-B107 | No <script> or <iframe> elements |
DUN-B108 | No Node globals (process.…, require(), __dirname) |
DUN-B109 | A default export (the component) |
Reviewers also check that the block works in at least two very different themes, handles empty settings, and is accessible (real headings, alt text, keyboard-reachable controls).
Example: an announcement bar embed
{
name: 'announcement',
title: 'Announcement bar',
kind: 'embed',
settings: [
{ key: 'text', label: 'Message', type: 'single_line_text', required: true, default: 'Free shipping over ₹999' },
{ key: 'link', label: 'Link', type: 'url' },
],
code: `import { useBlock, Link } from '@dunea/blocks';
export default function Announcement() {
const { settings } = useBlock();
if (!settings.text) return null;
const body = <span>{settings.text}</span>;
return (
<div className="bg-primary px-4 py-2 text-center text-sm text-primary-foreground">
{settings.link ? <Link to={settings.link} className="underline">{body}</Link> : body}
</div>
);
}
`,
}Continue with Themes for theme blocks and presets, or Publishing.
Custom data
Metafields and metaobjects: typed values on products, orders and pages, and content types the owner edits without code.
Admin extensions
Put your plugin's cards and actions inside Dunea's own panels: on a product, an order, a selection of orders, a funnel, a payment, a review or the app overview.