pandora-box-react
v0.4.1
Published
Puck-free, framework-agnostic runtime that renders low-code builder JSON with your own components (React web + React Native).
Downloads
87
Maintainers
Readme
pandora-box
A Puck-free, framework-agnostic runtime that renders low-code builder JSON with your own components. Give it the page document + a component registry + the manifest; it walks the JSON and renders. The same document renders on React (web) and React Native — only the registry differs. No editor, no Puck, no design-system lock-in.
┌──────────────────────────┐ page document (JSON) ┌──────────────────────────────┐
│ Builder (Contentful app │ ──────────────────────────▶ │ Your app │
│ / cross-platform-low- │ { root, content, zones } │ pandora-box-react renders │
│ code-engine) │ │ the doc with YOUR registry │
└──────────────────────────┘ └──────────────────────────────┘
▲ ▲
│ manifest.json (prop schema, generated by the extractor) │
└──────────────────── pandora-box-manifest ──────────────────┘The package family
| package | what it is | when you need it |
| --- | --- | --- |
| pandora-box-react (this repo) | The runtime: PageRuntime / createRuntime / Render. Walks the document, resolves i18n text, $media refs, actions, slots/arrays; renders via your registry. | Always. |
| pandora-box-manifest | The published prop schema (manifest.json + types). Tells the runtime which props are slots / arrays / text / images. Regenerated by the builder repo's extractor — never hand-edited. | Always (pass it to the runtime). |
| pandora-box-layout | The engine components: free-layout primitives (Overlay, Positioned, Flex, Card), Swiper, Typography, and the templates (HeroOverview, WhatsNew, UpcomingList, ServiceList). Styled parts read dp-design live tokens (white-label). | When your documents use engine components/templates (the standard setup does). |
| pandora-box-dp | The dp-design adapter: dpRegistry (the full @dragonpass/atom-ui-mobile component map, keyed by manifest type ids) + DpProvider (ConfigProvider wrapper) + DpConfig (locale context). | When your documents use dp-design components (the standard setup does). |
| cross-platform-low-code-engine | The builder monorepo: Puck editor, Contentful app, extractor (regenerates the manifest), playground. Ops author documents here. | You deploy it; app code never imports it. |
Version pairing (keep these in lockstep — the manifest describes what the components expose):
pandora-box-react ^0.3.0 · pandora-box-manifest ^0.2.8 · pandora-box-layout ^0.1.17 · pandora-box-dp ^0.2.1npm install pandora-box-react pandora-box-manifest pandora-box-layout pandora-box-dpPeers:
react >= 18;pandora-box-dp/pandora-box-layoutexpect the host app to provide@dragonpass/atom-ui-mobile(>= 1.1.0-beta.23, from the private registry) and its CSS — your app is already a dp-design app, so nothing is bundled twice.
Full integration (the standard dp-design setup)
This is the wiring used in production (@dragonpass/benefit-common), end to end.
1. Assemble the runtime once — dp components + engine components, the published manifest,
DpProvider as the wrapper, and your action dispatcher:
// pandora-page.tsx
import { createRuntime, type ComponentRegistry, type Manifest } from 'pandora-box-react';
import { manifest } from 'pandora-box-manifest';
import { dpRegistry, DpProvider } from 'pandora-box-dp';
import {
Flex, Card, Overlay, Positioned, Swiper, Typography,
MediaCaption, MediaCarousel, HeroOverview, WhatsNew, UpcomingList, ServiceList,
} from 'pandora-box-layout';
import { openWebview } from '@dragonpass/miniapp-jsbridge';
// registry = every type id the manifest knows: dp-design components + engine components.
// NB: `Swiper` comes from pandora-box-layout (full-bleed image + `dots` toggle) — it is
// NOT in dpRegistry since pandora-box-dp 0.2.1.
const registry = {
...dpRegistry,
Flex, Card, Overlay, Positioned, Swiper, Typography,
MediaCaption, MediaCarousel, HeroOverview, WhatsNew, UpcomingList, ServiceList,
} as unknown as ComponentRegistry;
export const PandoraPageRuntime = createRuntime({
registry,
manifest: manifest as unknown as Manifest,
wrapper: DpProvider, // dp-design ConfigProvider; reads locale from DpConfig
fallbackLocale: 'en',
onAction: (action) => {
if (action.type === 'navigate' && action.href) openWebview(action.href, {});
},
});2. Inject the active locale at your app root (DpProvider reads it):
import { DpConfig } from 'pandora-box-dp';
<DpConfig.Provider value={{ locale: currentLocale }}>
<App />
</DpConfig.Provider>3. Fetch the document and render. Documents live wherever your builder publishes them
(ours: a Contentful entry's document field):
import { type DocData } from 'pandora-box-react';
export const PandoraSection = ({ entryId }: { entryId: string }) => {
const { currentLocale } = useLang('common');
const [doc, setDoc] = useState<DocData | null>(null);
useEffect(() => { fetchPandoraDoc(entryId).then((d) => d && setDoc(d)); }, [entryId]);
if (!doc) return null;
// builder docs key localized text by SHORT codes ({en, zh}) — fall back accordingly
const short = (currentLocale || 'en').split('-')[0];
return <PandoraPageRuntime doc={doc} locale={currentLocale} fallbackLocale={short} />;
};4. Sizing (rem). Engine components emit inline sizes as rem against a 375px design base
(toRem(px), rootValue 37.5). If your app uses amfe-flexible (html font-size = viewport/10),
everything lines up out of the box. A different rem system? Configure once at startup:
import { configureRem } from 'pandora-box-layout';
configureRem({ rootValue: 16 }); // match your postcss-pxtorem rootValue
// or keep plain px: configureRem({ convert: (px) => `${px}px` });That's it. Anything ops publish from the builder now renders in your app.
Keeping registry ↔ manifest in sync
The builder repo's extractor regenerates pandora-box-manifest from the component sources —
whatever ops can drag in the builder is exactly what the manifest describes. Your registry must
cover those type ids; unknown types are skipped (never crash). When the family bumps
(layout/dp/manifest), update the three together and you're done — documents are forward-compatible
(old props that a component dropped are simply ignored).
White-label theming
Styled engine components read dp-design's live seed tokens (useToken() / var(--aum-*)),
and colour props store dp token names (e.g. "linkColor", resolved at render via
resolveColor; Overlay backgrounds accept "successColor 0.6" → color-mix). Re-skinning the
dp ConfigProvider theme re-skins every published page — no document changes needed.
Bring-your-own components (generic, no dp-design)
import { PageRuntime } from 'pandora-box-react';
import { registry } from './my-registry'; // type id → your component
import manifest from './manifest.generated.json'; // emitted by your builder/extractor
export function Page({ doc, locale }) {
return (
<PageRuntime
doc={doc} // the "Page JSON" the builder produces
registry={registry} // { Button, Image, Swiper, ... }
manifest={manifest} // tells the walker which props are slots / arrays / text
locale={locale} // "en" | "zh" | … → localized text resolves to this
fallbackLocale="en"
/>
);
}Pre-bind once with createRuntime
Bind your registry + manifest (+ an optional provider) once, get a tiny <Runtime doc locale />:
import { createRuntime } from 'pandora-box-react';
import { registry } from './my-registry';
import manifest from './manifest.generated.json';
import { ThemeProvider } from './theme'; // optional design-system provider
export const PageRuntime = createRuntime({
registry,
manifest,
wrapper: ThemeProvider, // receives `locale`; wraps the rendered page
fallbackLocale: 'en',
});
// anywhere:
<PageRuntime doc={pageJson} locale="zh" />React Native
Same component, native registry — the document JSON is identical across platforms:
import { createRuntime } from 'pandora-box-react';
import { registry } from './my-registry.native'; // your RN components by the same type ids
import manifest from './manifest.generated.json';
export const PageRuntime = createRuntime({ registry, manifest });How it works
- The document is
{ root, content: Node[], zones }; eachNodeis{ type, props }. - The
manifestdescribes each component's props. The walker uses it to know which props are slots (Node[]→ recurse), arrays (rows, each possibly with slots/text), and text (resolve a{ locale: string }map to the active language). - Unknown
node.types are skipped (or pass afallback). Each component is wrapped in an error boundary, so one bad component never crashes the page.
API
| export | description |
| --- | --- |
| PageRuntime | { doc, registry, manifest, locale?, fallbackLocale?, fallback?, wrapper?, onAction?, bindings?, transforms?, flags? } → renders the page |
| createRuntime | pre-bind { registry, manifest, wrapper?, fallbackLocale?, onAction? } → (props) => <Runtime/> |
| Render | the low-level walker: { data, registry, manifest, locale?, fallbackLocale?, fallback?, onAction?, bindings?, transforms?, flags? } |
| resolveLocalized(value, locale?, fallback?) | resolve a LocalizedString to a string |
| isLocalizedMap(value) | is a value a { locale: string } map? |
| resolveMedia(value) / isMediaRef(value) | resolve a { $media } image reference to its snapshot URL |
| isAction(value) | is a value a usable declarative Action? |
| applyTemplate(tpl, data) / hasTemplate(s) | fill / detect {{ … }} slots — the same functions the walker uses |
| resolveBinding(rows, fields) / mapItem / getByPath | map a raw array onto a list component's items |
| types | DocData, Node, Manifest, ComponentManifest, ManifestField, FieldDescriptor, LocalizedString, MediaRef, MediaValue, Action, ActionTarget |
i18n
Text props can be a plain string or a { locale: string } map:
{ "type": "Typography", "props": {
"text": { "en": "Enjoy your Travel in China", "zh": "畅游中国之旅" } } }The runtime resolves text to locale (→ fallbackLocale → first available → as-is). Plain
strings keep working unchanged, so localization is fully backward-compatible.
Media ($media)
An image field's value can be a plain URL or a hybrid media reference carrying a CMS
asset id plus a denormalized snapshot:
{ "src": { "$media": { "provider": "contentful", "kind": "asset", "id": "6WoO…", "url": "https://images.ctfassets.net/…/2.jpg" } } }The runtime resolves it to the snapshot url before it reaches your component (top-level
and inside array rows). Plain URL strings pass through unchanged.
Actions (onAction)
Documents are props-only JSON, so interactions are stored as DATA — an action descriptor
({ type: 'navigate', href } or { type: 'event', name, payload? }). The runtime turns it
into a real onClick that calls YOUR onAction dispatcher (top-level action prop and
per-row inside array fields with an action item field):
<PageRuntime
doc={doc}
registry={registry}
manifest={manifest}
onAction={(action) => {
if (action.type === 'navigate') openWebview(action.href);
}}
/>The host decides what navigation means (web location, in-app webview, RN navigator) —
the document stays portable.
Data (bindings)
Same idea as actions: the document says WHAT it wants, you fetch it and pass it in. Any text
prop can carry {{ … }} slots, filled from bindings — an email template, basically:
{ "type": "Typography", "props": {
"text": "{{ guests }} people at {{ time }} on {{ date }}" } }<PageRuntime doc={doc} registry={registry} manifest={manifest}
bindings={{ guests: 2, time: '14:00', date: '8 June 2026' }} />The shape is yours. Use {{ a.b }} to reach into a nested object, which is also how several
sources coexist without colliding:
bindings={{ booking: { time: '14:00' }, flight: { time: '16:30' } }}
// "{{ booking.time }}" and "{{ flight.time }}" stay distinctThe engine never interprets a value — it substitutes what you passed, so a value already
formatted the way you want it is the simplest thing that works. If you'd rather format in the
page, {{ at | date }} / | time / | upper / | lower / | default:x / | map:A=x,B=y
are available, and | transform:name calls one of your own transforms functions.
Dates keep the offset the value carries: 2026-06-08T14:00:00+01:00 renders as 14:00 on every
device, not re-stated in the reader's timezone. A value with no offset (2026-06-08T14:00, or an
epoch number) never said where it was, so it falls back to the device.
A path that isn't in bindings renders empty and the rest of the sentence still ships. Text with
no {{ … }} is untouched, so this costs nothing for ordinary copy.
For a LIST, a data-bound component's binding.source names an array in bindings and the saved
field map shapes each row onto its items.
License
MIT
