efficient-storefront-blocks
v0.61.0
Published
The EFFICIENT storefront's website editor — the website builder assembled with the storefront's own sections — for an app that edits a storefront website inside its own page. Built from the efficient-storefront-blocks repository.
Readme
efficient-storefront-blocks
The EFFICIENT storefront's website editor — the website builder (the page, template and section editors) assembled with every section the storefront renders — for an app that edits a storefront website inside its own page. The admin portal's Storefront editor windows mount it.
Published as efficient-web up to 0.3.0 and as efficient-web-builder from
0.7.0 to 0.10.0; 0.11.0 is the first release under this name (CHANGELOG.md
says how to migrate). The name efficient-web-builder now belongs to the
builder alone, published from its own repository
(Efficient-Pty-Ltd/efficient-web-builder):
the editor, the general sections and the renderer, with no storefront in it.
It is not a second copy of anything: build.mjs compiles this repository's
src/ (the storefront's blocks, editor session and API client), starting at
package/src/index.tsx, against the efficient-web-builder npm package, and
draws the editor canvas with the site's own sheet (src/render/app/globals.css,
through package/canvas.css). The storefront itself never installs it: it
vendors src/ and its pages import the same source directly.
Use
import "efficient-storefront-blocks/editor.css";
import { WebsiteEditor, createEditorHost } from "efficient-storefront-blocks";
const { host, deliver } = createEditorHost({
onMessage: (m) => { /* toasts, open the media picker, the form builder … */ },
address: { search: () => "", show: () => {}, load: retargetWindow, reload: remount },
});
// Grant a token when the editor says it is ready, and again on auth-expired:
// deliver({ type: "storefront-editor:auth", token, siteId, can })
<WebsiteEditor host={host} target={{ kind: "page", slug: "homepage", siteId }} site={{ slug, origin }} />;- Messages are the
storefront-editor:*protocol the editor once spoke over postMessage from the shop's/editroutes, as function calls — no origin, no iframe.EditorToHostMessage/HostToEditorMessagetype every one. site— the website'sslug(public catalog reads name it with?site=, because the embedder's own host names no website) and its publicorigin(the routes only the storefront serves, and the Preview link). Re-mount the editor with akeyto move to another website.- One editor mounted at a time. Parts of the editor keep the open website and page in module state; route every target into one window.
- Styles.
efficient-storefront-blocks/editor.cssis the chrome, scoped under.sf-editor; the engine's sheets are imported from the embedder's ownefficient-easel. Neither carries the shell's: sinceefficient-easel1.0 (0.15.0) every editor control is anefficient-shell/uicomponent, so the embedder compilesefficient-shell/ui.css(orstyles.css) through its own Tailwind v4 build and loads it before any easel sheet. The canvas is styled from inside its own frame (the storefront's compiledapp/globals.css), so neither page's styles reach the other. - Peers:
react/react-dom19,efficient-easel,efficient-shell.
A backend of your own (0.4.0)
The editor reaches its server only through a WebsiteBackend
(doc/adr/0002). Leave backend out and it is EFFICIENT's — /api/storefront/*,
authorised by the token you deliver with storefront-editor:auth, exactly as
above. A product with its own backend passes its own:
import { WebsiteEditor, WebsiteBackendError, type WebsiteBackend } from "efficient-storefront-blocks";
const backend: WebsiteBackend = {
pages: {
list: () => api.pages(), // EditorPageRow[]
load: (ref) => api.page(ref), // { id, layout, version } | null
save: async (id, doc, expect) => {
const res = await api.savePage(id, doc.layout, expect.version);
if (res.conflict) {
throw new WebsiteBackendError("changed-elsewhere", undefined, {
conflict: { savedBy: res.by, savedByYou: res.mine, secondsAgo: res.ago },
});
}
return { version: res.version };
},
},
site: { get: () => api.site() }, // { name }
media: { resolve: (ref) => (ref.startsWith("file:") ? cdn(ref) : null) },
};
<WebsiteEditor host={host} target={{ kind: "page", slug: "home" }} site={null} backend={backend} />;- Required:
pages(list,load,save),site.get,media.resolve. Optional, each switching its own control on:pages.version,pages.publish(the main button reads Publish, not Save),pages.create/pages.remove(inline in the page list),lock(the "somebody else is editing" notices and take-over),site.update(the Maps key),preview(the Preview link; withdraftLink, Preview opens the saved draft),sectionTemplates,templates. A missing group is never a dead button: the editor does not draw what it cannot do. - Failures are kinds. Reject with
WebsiteBackendError:unauthorized,changed-elsewhere(+conflict),lock-taken(+holder),not-found,invalid,unavailable. The editor never reads a status code or a body. - No token. How a call is authorised is the backend's business. The editor
waits for no
authmessage and needs none; it announcesreadyonce, and when a save, a publish or a lock claim is refusedunauthorizedit tells the hostauth-expiredand lets the autosave try again. - No request of its own. Under your backend the package's own HTTP client is closed and the canvas loads no font sheet from Google.
PageBundleis{ layout, translations?, seo? }. 0.4.0 reads and writeslayoutonly; store the bundle anyway —translationsandseoare where per-language content will live, and an absent member means "leave it".- Not behind the interface yet (they work under EFFICIENT's backend, and are
absent under yours): site styles, the pinned header and footer, menus,
forms, the catalog, "duplicate to page". Block
settings that list such data (product link targets, product sources) list
nothing, and a Form section's picker is inert: it offers no list, no "New
form" and sends no
open-form. backend.pages.linkTargets(optional): your pages with their layouts, for the link picker. Without it the picker listspages.listand no anchors.backend.media.maxUploadBytes(optional): the largest file a media field takes before it reaches your panel or picker; leave it out to refuse none.backend.theme(optional):get/updatethe website's appearance — theSiteThemeRowbehind Site styles (colour themes, fonts and sizes, corners, cards, widths).updateis handed only the keys that changed and answers the whole row. Without it the editor shows no Site styles and the website is drawn in the built-in look.backend.social(optional):get/updatethe website's social links, one address per platform name. Reject a bad address withnew WebsiteBackendError("invalid", message, { fields: { instagram: "…" } })and the reason is shown beside that platform's box.backend.chat(optional):get/updatethe live-chat bubble's settings. (The Live chat panel itself is still drawn only where the site's settings are — EFFICIENT's backend — until those settings cross too.)backend.blog(optional):posts(scope?)answers your published posts. With it a section's "Blog post source" and the link picker's "Blog post" list your blog; without it neither offers a list.backendis read once, when the editor mounts — re-mount (akey) to change it.
A host that is not the admin portal (0.19.0)
The editor's calls to EFFICIENT's backend are authorised by the host's own
token, and each call names the portal that token was minted for
(X-Portal-Audience). The backend refuses a token whose portal is not the one
named ("Token was issued for a different portal"). Until 0.19.0 every call
said admin. A host that is another portal names its own, once, on the
backend it passes:
import { WebsiteEditor, createEditorHost, createEfficientBackend } from "efficient-storefront-blocks";
const [backend] = useState(() => createEfficientBackend({ portalAudience: "venue" }));
<WebsiteEditor host={host} target={target} site={{ slug, origin }} backend={backend} mediaPanel={MediaPanel} />;portalAudienceis"admin"(the default: leave it out and nothing changes) or"venue", the venue portal, where a business edits its own website. It is carried by every request the package makes with the host's token: the page and its lock, publish, site styles, the header, menus, forms, media uploads, section templates. A value the package does not know is sent asadmin.- It is still EFFICIENT's backend. The editor waits for the token
(
storefront-editor:auth) and keeps its own client open, exactly as with nobackendat all. - It is the host's, not one editor's. A host is one portal, so the
audience is kept for the page from the moment it is named: every editor
mounted afterwards carries it, and so do an editor's last calls after it
unmounts (the lock's release, a final save). Make the backend before the
first editor mounts. A backend made without
portalAudienceleaves it as it is. - What the backend answers is the backend's rule. For
venue: efficient-backenddocs/adr/0049("Amended 2026-10-12"). A venue login that holds the website right reaches the routes of a brochure website (pages, the lock, publish, the site and its settings, theme, header and social links, menus, forms, media, section templates, and the template catalogue to read). The shop's routes stay the admin portal's.
What such a host supplies
The editor draws the canvas, the block panels, Site styles and the header
panel. Everything round it is the host's, as it is the admin portal's
(admin-portal/src/pages/storefront/, src/components/website/):
| The host supplies | How the editor asks for it |
|---|---|
| The token, and a fresh one when it runs out | It sends storefront-editor:ready, and storefront-editor:auth-expired when a call is refused; answer each with deliver({ type: "storefront-editor:auth", token, siteId }) |
| A window to mount it in, and its address | createEditorHost({ address }): search, show, load, reload; it sends storefront-editor:navigated when it moves to another page |
| A media library | the mediaPanel prop (MediaPanelComponent, drawn once as the editor's popup: open while pick or browsing is set, closed by onClose). Without one, each image field sends storefront-editor:open-media-picker; answer with storefront-editor:media-picked or storefront-editor:media-pick-cancelled |
| A picture dropped from that popup | the mediaPlace prop (MediaPlace): what the file becomes on this host before the editor places it (its pick path). Without one, the file as it came |
| The page list's settings screen, and the new-page flow | storefront-editor:open-page (slug) and storefront-editor:new-page. The new-page flow is where a page is made blank or from the website's template library (GET /api/storefront/pages/template-library/, POST /api/storefront/pages/from-template/) |
| Menus | storefront-editor:open-menus (menuId); tell the editor with storefront-editor:menus-updated when one was saved |
| Forms, and the inbox of what was sent | storefront-editor:open-form (mode: "create" with a requestId, or "edit" with a formId); answer a create with storefront-editor:form-saved or storefront-editor:form-closed |
| The website's settings and its address | nothing asks: a screen of the host's own (/api/storefront/sites/, sites/{id}/address/). The editor itself saves one site field, the Maps key |
| The template picker | nothing asks: making a website from a look or from nothing is the host's (GET /api/storefront/page-templates/, POST /api/storefront/sites/ with template) |
| Toasts and the unsaved-changes guard | storefront-editor:dirty, saved, published, save-failed |
storefront-editor:open-product, open-category, open-post and
save-as-template are sent only from the shop's blocks, the blog source and
an operator's template editor. A host that offers none of those never
receives them (blocks: { sets: [...] }, above; leave can out of the
auth message).
What still assumes the admin portal
Listed so that a host knows what it will meet. None of it is switched by
portalAudience.
- Calls the backend answers only to the admin portal. With
portalAudience: "venue"the backend refuses these (403): the product catalogue and one product's details (/api/storefront/products/,wheel-curations/), categories (categories/), the product field answers (field-usage/), blog posts (blog-posts/) and the live-chat settings (chat-settings/). A refused list reads as an empty one (the catalogue, categories, the field answers) or as "Couldn't reach" (the blog source). The Live chat panel is still drawn: it loads nothing and its save is refused. Offer only the block sets a brochure website needs (blocks.sets) and the catalogue is never asked for. To stop the editor asking for the chat settings or the blog, leave the group out of the backend you pass:const { chat, blog, ...backend } = createEfficientBackend({ portalAudience: "venue" })(it is still EFFICIENT's backend). - Words that name the admin portal's menu, until the host renames them.
Hints such as "Create menus in Storefront → Menus", "Goes to Storefront →
Forms", "Syncing from Storefront → Blog" and "Storefront → Categories" say
where those screens are in the admin portal. Since 0.26.0 each place is a
phrase of its own in the editor's words ("Storefront → Menus", "Storefront →
Forms", "Storefront → Blog", "Storefront → Categories", "Storefront → Booking
Services", "Settings"; the builder's "Storefront ▸ Sites ▸ Settings" and "the
admin"), so a host names its own places in
words, in English too (below). brandIdandcan.canCreatePlatformTemplatein theauthmessage are the admin portal's and the operator's. Leave both out.- Template targets.
target: { kind: "template" }edits a row of the template catalogue, which only the admin portal may write. - The Preview link and the shop's own routes (fitment, geocoding, logo
analysis) go to
site.origin, the published website, whatever the host is.
Nothing in the package reads /api/auth/me/, a permission name or a feature
flag: what the signed-in person may do is the host's to know, and it says so
by what it mounts and what it puts in can.
The editor in the host's language (0.26.0)
The editor speaks English. A host in another language hands it its words, the language they are in, and the language the backend should answer in:
import { EaselStringsProvider } from "efficient-easel/strings";
import { WebsiteEditor, createEfficientBackend } from "efficient-storefront-blocks";
import type { WebsiteEditorCatalogue } from "efficient-storefront-blocks/words";
const de: WebsiteEditorCatalogue = { Pages: "Seiten", "Delete {name}": "{name} löschen", /* … */ };
const [backend] = useState(() => createEfficientBackend({ portalAudience: "venue", language: "de" }));
<EaselStringsProvider value={easelDe} locale="de">
<WebsiteEditor host={host} target={target} site={site} backend={backend} words={de} locale="de" />
</EaselStringsProvider>;wordsare keyed by the English they replace. Every phrase is listed byefficient-storefront-blocks/words(a light entry, like/contract):WEBSITE_EDITOR_WORDS, the website builder's phrases (WEB_BUILDER_WORDS) and the blocks' own (STOREFRONT_BLOCKS_WORDS), each its own English. Type a language's file asWebsiteEditorCatalogueand a phrase a release adds fails your typecheck until it is said;WebsiteEditorWordstakes any of them.- A sentence names its values in braces, in English and in your word alike
(
"Delete {name}": "{name} löschen"), or give a function of the values for a plural or an agreement the braces cannot say. A count is two phrases ("{n} item","{n} items"). - A word left out reads in English. Words are read when the editor mounts and for as long as it is mounted: a language is fixed for the life of a page.
- The names of the admin portal's places are phrases of their own (above): give your own places for them, even in English.
- A sentence names its values in braces, in English and in your word alike
(
- The engine's words are efficient-easel's: wrap the editor in its
EaselStringsProvider. The editor hands them on to the engine's parts it draws itself. localeis the BCP 47 tag your words are in.createEfficientBackend({ language })sendsAccept-Languageon every call the editor makes, so what the backend says back — a refused save, a page it will not open — is in your language where your portal offers it. Left out, the browser's own.- What is written into a page stays English, and what a visitor reads on the published website is never the editor's words: a block's default content, a form a visitor fills, the cart and the checkout are the site's.
Which blocks the editor offers (0.7.0)
Every block is in one of three sets:
| Set | What it is |
|---|---|
| general | Draws from its own props and what the host hands it (media, the site's logo, its Maps key). Any host may offer it. |
| commerce | The shop: products, the catalogue, the cart, the checkout, the pinned Header. |
| service | Needs one of the EFFICIENT backend's site services: forms, bookings, reviews, the newsletter, the blog, menus, the pinned Footer. |
Which block is in which set is GENERAL_BLOCKS, COMMERCE_BLOCKS and
SERVICE_BLOCKS in efficient-storefront-blocks/contract.
Leave blocks out and the editor offers all of them, exactly as before 0.7.0.
Pass it to choose:
import { WebsiteEditor, type WebsiteEditorBlocks } from "efficient-storefront-blocks";
import type { ComponentConfig } from "efficient-easel/blocks";
const Roster: ComponentConfig = {
label: "Roster",
fields: { heading: { type: "text", label: "Heading" } },
defaultProps: { heading: "On tonight" },
// `puck.metadata` is whatever you hand the editor as `blocks.metadata`.
render: ({ heading, puck }) => <RosterSection heading={heading} people={puck.metadata.roster} />,
};
const blocks: WebsiteEditorBlocks = {
sets: ["general"], // default: all three
deny: ["CustomHTML"], // never these
// allow: [...], // or: only these (after `sets`)
host: { "acme-roster": Roster }, // your own blocks, by type
categories: [{ key: "acme", title: "Venue", components: ["acme-roster"], before: "openers" }],
metadata: { roster }, // live: change it and the canvas redraws
};
<WebsiteEditor host={host} target={target} site={null} backend={backend} blocks={blocks} />;- Offered, not registered. A block you leave out is in no group of the Add panel, no slot's "Add block" menu and no drop target, and no section is seeded with it. It is still registered: a page that already holds one draws it, and it can be selected and deleted.
- A section is offered only with what it is built from. Adding a Hero
writes Hero Slides, and "Brand Text" lays down Boxes, a Heading and a Text —
so a Hero is not offered where
HeroSlideis not, and Brand Text is not where the Box is not.allow: ["Hero"]therefore offers nothing;allow: ["Hero", "HeroSlide"]offers the Hero. To see what a section writes, add it and runblockTypesInon the saved layout. A section that is only wrapped in a Box when it is added (most sections with a heading) is added whole instead where the Box or the Section Header is not offered. - With a set left out, what needs it is not drawn. Without
service: no "Blog post" link target, no "Blog post source" on Feature Banner, Article Text and Post Card, an inert Form picker. Withoutcommerce: no "Product" link target, no catalog seed. The pinned Header and Footer need both. - Your own blocks. Written as the engine's
ComponentConfig, with its field types (text,textarea,number,select,radio,array,object,custom,slot). A type the editor already registers is refused — the editor throws when it mounts — so give yours a prefix. A block is added from the Add panel, in the groupcategoriesnames; one named in no group is registered and offered nowhere, and none is offered inside a Box. Acategoriesentry whosekeyis an existing group (content,structure, …) adds to the end of that group. - Your blocks get none of the editor's own section rows. The Padding,
Width, Height, Anchor, Theme, Corners and Animation rows, the four settings
tabs and click-to-edit text are added to the editor's own blocks by ~25
passes that run once, when the package loads, over lists fixed in its source.
A block that arrives when the editor mounts is not on those lists, so it is
drawn exactly as its
renderdraws it and edited with exactly the fields it declares. The one exception is the Layers tree's eye, which hides your block as it hides any other (it stores_hidden: trueon the node's props). Giving host blocks the rest needs anapplyBlockPasses(type, block)the package does not have yet: each pass rewritten from a loop over a fixed list into a function of one block, run in the same order. metadatais live; the rest is read once.sets,allow,deny,hostandcategoriesare read when the editor mounts, withbackend— re-mount (akey) to change them.metadatais followed: hand the editor a new object and every block in the canvas redraws in place. It is never written to the page or to the undo history. Keep the object the same between renders while the data is the same (auseMemo, a state value): a new object each render redraws the canvas each render.
Checking a saved page on your server
Hiding a block in the editor is not a security boundary. blocks decides
what the editor offers; a page is a JSON document, and whoever can call your
save endpoint can send one holding any block type. Check it where you store it:
import { GENERAL_BLOCKS, blockTypesIn } from "efficient-storefront-blocks/contract";
const ALLOWED = new Set([...GENERAL_BLOCKS, "acme-roster"]);
function assertAllowed(layout: unknown) {
const refused = blockTypesIn(layout).filter((type) => !ALLOWED.has(type));
if (refused.length) throw new Error(`This page holds blocks this site does not allow: ${refused.join(", ")}`);
}efficient-storefront-blocks/contract is one small file that imports nothing — no React, no
engine, no editor — so a server can load it. It exports BLOCK_SETS,
GENERAL_BLOCKS, COMMERCE_BLOCKS, SERVICE_BLOCKS, blockSetOf(type) and
blockTypesIn(layout).
blockTypesIn reads a layout by shape: any { type, props } in the page's
content, in a zones entry, or in an array under a node's props is a block,
to any depth. It errs wide — something that merely looks like a block is
counted — so a check built on it can refuse a page it might have accepted, and
never the reverse. If ALLOWED is narrower than what your editor offers, list
the blocks your sections are built from as well (see above), or the editor will
build pages your server refuses.
Drawing a link the way the editor does
efficient-storefront-blocks/links is the editor's link rules, for a host that draws the
shared link picker (efficient-easel LinkPicker) somewhere else — the admin's
menu items. It is built from the same file the editor's own Link field reads,
so a link written in one place reads back the same in the other.
import {
LINK_KINDS, inferLinkKind, isSafeLink, listingFilterFields,
listingTarget, productListingPaths, readListingFilter, withListingFilter,
} from "efficient-storefront-blocks/links";
const listing = productListingPaths(pages, { fallback: sellsProducts });
inferLinkKind("/shop?type=tyre", pagePaths, listing); // "page"
withListingFilter("/wheels", { param: "collection", value: "Precision Forged" });
// "/wheels?collection=Precision%20Forged"
isSafeLink("javascript:alert(1)"); // falseisSafeLink is efficient-shell's one address rule, isSafeLinkHref, asked on
the trimmed text — the rule the shell's LinkPicker applies in the editor.
listingFilterFields(catalogue, { listing, fieldUsage }) lists the filters a
link onto the product listing may offer — the listing's own rail for the kinds
the section shows, after the site's Product Types answers and the one-value
rule — each with the values the site's catalogue has. Light like /contract:
one file, no import of any kind.
Release
The version here is bumped by hand in the pull request that wants the admin
to see a change (and a CHANGELOG.md section added). After it merges:
gh release create vX.Y.Z --target <merge sha> --notes-file <the CHANGELOG section>.github/workflows/release.yml builds and publishes it (npm trusted
publishing). Then bump the admin portal's pin. Up to 0.13.0 the package was
built in efficient-storefront (packages/website) and released there under
web-vX.Y.Z tags.
Closed source: © Efficient Pty Ltd, all rights reserved (LICENSE); the
published files are minified and ship no source maps.
