diff --git a/README.md b/README.md
index a0594f0..a3b92fe 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,7 @@ One folder per marketplace kind:
| Folder | Kind | Examples |
| --- | --- | --- |
-| [`plugins/`](examples/plugins) | Plugins | [`arrange-grid`](examples/plugins/arrange-grid) (commands, a UI, storage), [`rename-layers`](examples/plugins/rename-layers) (UI panel, events, storage), [`contrast-checker`](examples/plugins/contrast-checker) (WCAG checks, `findNodes`, `setSelection`), [`pop-palette`](examples/plugins/pop-palette) (paint styles) |
+| [`plugins/`](examples/plugins) | Plugins | [`arrange-grid`](examples/plugins/arrange-grid) (commands, a UI, storage), [`rename-layers`](examples/plugins/rename-layers) (UI panel, events, storage), [`contrast-checker`](examples/plugins/contrast-checker) (WCAG checks, `findNodes`, `setSelection`), [`pop-palette`](examples/plugins/pop-palette) (paint styles), [`storefront-preview`](examples/plugins/storefront-preview) (a File → Export format built on `getStorefront()`) |
| [`widgets/`](examples/widgets) | Canvas widgets (plugins that register a widget) | [`vote-counter`](examples/widgets/vote-counter), [`poll`](examples/widgets/poll), [`checklist`](examples/widgets/checklist) (named handlers) |
| [`themes/`](examples/themes) | Theme packs (data-only plugins) | [`comic-themes`](examples/themes/comic-themes) (two light themes), [`comic-noir`](examples/themes/comic-noir) (semi-dark) |
| [`brushes/`](examples/brushes) | Brush packs | [`comic-inkers`](examples/brushes/comic-inkers): liner, brush pen, stipple |
@@ -51,6 +51,27 @@ Install one from **Main menu → Manage plugins… → Install from file…**, w
folder (`manifest.json` at the root) or a single-file `.json` bundle:
`{ "manifest": { … }, "main": "…code…", "ui": "…" }`.
+## Importers and exporters
+
+A plugin can add a File → Import or File → Export format in its manifest:
+
+```json
+"contributes": {
+ "importers": [{ "id": "pub", "label": "Publisher (.pub)", "accept": [".pub"] }],
+ "exporters": [{ "id": "ai", "label": "Adobe Illustrator (.ai)", "extension": ".ai" }]
+}
+```
+
+An importer runs with `popcraft.importMode` and `popcraft.importFile` set, and builds the document with the
+editor methods. An exporter runs with `popcraft.exportRequest` set, renders what it needs with `exportNode`, and
+hands the file back with `popcraft.saveExport({ name, bytes })`.
+
+An exporter for a store reads the open page as a storefront with `popcraft.getStorefront()`: each page's HTML with
+the store's data marked rather than filled (`{{page.title}}`, `{{item.price}}`, lists between
+`` and ``, forms with
+`data-pc-action`), and the collections it reads with their schema and sample records. It turns those marks into its
+platform's templates; [`storefront-preview`](examples/plugins/storefront-preview) fills them with the samples instead.
+
## Publishing to the marketplace
Each item folder holds one of `manifest.json` (a plugin, widget or theme pack), `pack.json` (a brush or
diff --git a/examples/plugins/ecommerce/README.md b/examples/plugins/ecommerce/README.md
new file mode 100644
index 0000000..c13b51d
--- /dev/null
+++ b/examples/plugins/ecommerce/README.md
@@ -0,0 +1,25 @@
+# Ecommerce
+
+A store for a storefront design. Everything about Shopify lives in this plugin, none of it in the editor:
+
+- **Store** (a tab on the side of the editor): connect a Shopify store by its `.myshopify.com` address and the public
+ Storefront access token (Shopify admin › Sales channels › Headless › your storefront › Storefront API). It is kept
+ with the file, and set as the site environment (`SHOPIFY_STORE_DOMAIN`, `SHOPIFY_STOREFRONT_ACCESS_TOKEN`) that
+ **Publish › Store** deploys a Next.js Commerce store with.
+- **Catalogue**: the store's products as pictures, by category, a product's handle or a search, sorted and limited.
+ Click a picture to place it on the page; **Use in the design** fills the design's products (and categories) with
+ them, so the canvas shows what the store sells.
+- **Make it a store**: the collections a store needs (products, categories, cart, cart lines, menu), with samples,
+ or the store's own products when one is connected.
+- **Checklist**: what the design still needs to sell, the layer to look at, and **Fix** where the plugin can do it
+ (a store form's hidden field: add to cart sends `{{item.variant_id}}`, a cart line's form `{{item.merchandise_id}}`).
+- **Export › Shopify theme (.zip)**: an Online Store 2.0 theme of the design, to upload in Online Store › Themes.
+
+## How it is built
+
+- `contributes.panels` docks the UI (`ui.html`) as the Store tab; `contributes.exporters` adds the theme to Export.
+- The catalogue is read straight from the store's Storefront API (it answers any origin), with the `network`
+ permission. Nothing goes through PopCraft's servers.
+- The design is changed with the editor's own commands: `editCollection`, `editRecords`, `getRecords`, `setControl`,
+ `createNode`, `createImage`, `setSiteEnvironment`; its settings are kept with `setFileData`.
+- The theme is the editor's own Shopify theme target (`getStorefrontFiles('shopify-theme')`), saved with `saveExport`.
diff --git a/examples/plugins/ecommerce/icon.svg b/examples/plugins/ecommerce/icon.svg
new file mode 100644
index 0000000..0ecad8e
--- /dev/null
+++ b/examples/plugins/ecommerce/icon.svg
@@ -0,0 +1 @@
+
diff --git a/examples/plugins/ecommerce/main.js b/examples/plugins/ecommerce/main.js
new file mode 100644
index 0000000..e8f73c9
--- /dev/null
+++ b/examples/plugins/ecommerce/main.js
@@ -0,0 +1,293 @@
+///
+
+// Ecommerce — a store for a storefront design, as a plugin: everything about Shopify lives here, none of it in the
+// editor. Its Store tab (contributes.panels) connects a Shopify store, browses its real catalogue, fills the design
+// with it, makes any design a store and checks what it still needs; its exporter (contributes.exporters) writes a
+// Shopify theme. The editor keeps only what every store shares: the design's data, its forms, and Publish › Store,
+// which deploys the design as a Next.js Commerce store with the site environment this plugin sets.
+//
+// Shopify's Storefront API answers any origin (CORS *), so the catalogue is read straight from the store with its
+// public token — the token every visitor's browser uses on a headless store, which is why it may be kept in the file.
+
+const API_VERSION = '2025-07'
+/** What the plugin keeps with the open file (popcraft.setFileData): the store this design sells from. */
+const STORE_KEY = 'store'
+
+// ─── Shopify ─────────────────────────────────────────────────────────────────────────────────────────────────────
+
+/** A store's address as Shopify names it, from what was pasted (a URL, an admin link, the bare name). */
+function shopDomain(input) {
+ const host = String(input || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '')
+ if (/^[a-z0-9][a-z0-9-]*\.myshopify\.com$/.test(host)) return host
+ if (/^[a-z0-9][a-z0-9-]*$/.test(host)) return `${host}.myshopify.com`
+ return null
+}
+
+async function shopify(store, query, variables = {}) {
+ const res = await fetch(`https://${store.domain}/api/${API_VERSION}/graphql.json`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', 'x-shopify-storefront-access-token': store.token },
+ body: JSON.stringify({ query, variables }),
+ })
+ const body = await res.json().catch(() => ({}))
+ if (!res.ok || body.errors?.length) throw new Error(body.errors?.[0]?.message || (res.status === 401 || res.status === 403 ? 'Shopify refused the token' : `Shopify answered ${res.status}`))
+ return body.data
+}
+
+const PRODUCT = `id handle title description availableForSale tags
+ priceRange { minVariantPrice { amount currencyCode } maxVariantPrice { amount currencyCode } }
+ featuredImage { url altText width height }
+ variants(first: 1) { nodes { id } }`
+
+/** Shopify's sort keys: a collection's products sort by CREATED, a search's by CREATED_AT. */
+const SORTS = {
+ newest: { products: 'CREATED_AT', collection: 'CREATED', reverse: true },
+ 'price-asc': { products: 'PRICE', collection: 'PRICE', reverse: false },
+ 'price-desc': { products: 'PRICE', collection: 'PRICE', reverse: true },
+ title: { products: 'TITLE', collection: 'TITLE', reverse: false },
+ 'best-selling': { products: 'BEST_SELLING', collection: 'BEST_SELLING', reverse: false },
+}
+
+/**
+ * The store's products for a query: one product by its handle, a category's (a Shopify collection's handle), or a
+ * search over the whole catalogue; sorted, at most `first`.
+ */
+async function products(store, { category = '', product = '', q = '', sort = 'newest', first = 24 }) {
+ const n = Math.max(1, Math.min(Number(first) || 24, 100))
+ const s = SORTS[sort] || SORTS.newest
+ if (product) {
+ const d = await shopify(store, `query($h: String!) { product(handle: $h) { ${PRODUCT} } }`, { h: product })
+ return d.product ? [d.product] : []
+ }
+ if (category) {
+ const d = await shopify(store, `query($h: String!, $n: Int!, $k: ProductCollectionSortKeys, $r: Boolean) {
+ collection(handle: $h) { products(first: $n, sortKey: $k, reverse: $r) { nodes { ${PRODUCT} } } } }`,
+ { h: category, n, k: s.collection, r: s.reverse })
+ const list = d.collection?.products.nodes ?? []
+ // A category's products are searched here: Shopify's search spans the whole catalogue, not one collection.
+ return q ? list.filter(p => `${p.title} ${p.description} ${p.tags.join(' ')}`.toLowerCase().includes(q.toLowerCase())) : list
+ }
+ const d = await shopify(store, `query($n: Int!, $k: ProductSortKeys, $r: Boolean, $q: String) { products(first: $n, sortKey: $k, reverse: $r, query: $q) { nodes { ${PRODUCT} } } }`,
+ { n, k: s.products, r: s.reverse, q: q || null })
+ return d.products.nodes
+}
+
+async function categories(store) {
+ const d = await shopify(store, '{ collections(first: 100, sortKey: TITLE) { nodes { handle title description } } }')
+ return d.collections.nodes.filter(c => c.handle !== 'frontpage')
+}
+
+const money = m => (m ? new Intl.NumberFormat('en-US', { style: 'currency', currency: m.currencyCode }).format(Number(m.amount)) : '')
+
+/** A Shopify product as the design's product fields (commerce.product's keys). */
+const productValues = p => ({
+ title: p.title, handle: p.handle, url: `/product/${p.handle}`, description: p.description,
+ price: money(p.priceRange.minVariantPrice), max_price: money(p.priceRange.maxVariantPrice),
+ image: p.featuredImage?.url ?? '', image_alt: p.featuredImage?.altText || p.title,
+ available: p.availableForSale, variant_id: p.variants.nodes[0]?.id ?? '', tags: p.tags.join(', '),
+})
+
+// ─── The design ──────────────────────────────────────────────────────────────────────────────────────────────────
+
+/** What a store's collections are called when this plugin makes them, and a few samples each starts with. */
+const STORE_COLLECTIONS = [
+ { schema: 'commerce.product', name: 'Products', samples: [
+ { title: 'Canvas tote', handle: 'canvas-tote', url: '/product/canvas-tote', description: 'Heavy waxed canvas with leather handles.', price: '$48.00', image_alt: 'Canvas tote', available: true, variant_id: 'gid://shopify/ProductVariant/1', tags: 'bags' },
+ { title: 'Wool beanie', handle: 'wool-beanie', url: '/product/wool-beanie', description: 'Ribbed merino, one size.', price: '$32.00', image_alt: 'Wool beanie', available: true, variant_id: 'gid://shopify/ProductVariant/2', tags: 'hats' },
+ { title: 'Field notebook', handle: 'field-notebook', url: '/product/field-notebook', description: 'Ninety-six dot-grid pages that lie flat.', price: '$14.00', image_alt: 'Field notebook', available: true, variant_id: 'gid://shopify/ProductVariant/3', tags: 'paper' },
+ ] },
+ { schema: 'commerce.collection', name: 'Categories', samples: [
+ { title: 'Bags', handle: 'bags', url: '/search/bags', description: 'Totes, packs and pouches.' },
+ { title: 'Hats', handle: 'hats', url: '/search/hats', description: 'For every season.' },
+ ] },
+ { schema: 'commerce.cartLine', name: 'Cart lines', samples: [
+ { title: 'Canvas tote', variant: '', url: '/product/canvas-tote', quantity: 1, price: '$48.00', merchandise_id: 'gid://shopify/ProductVariant/1' },
+ ] },
+ { schema: 'commerce.cart', name: 'Cart', samples: [{ count: 1, subtotal: '$48.00', taxes: 'Calculated at checkout', total: '$48.00', checkout_url: '' }] },
+ { schema: 'commerce.menu', name: 'Menu', samples: [{ title: 'Shop all', url: '/search' }, { title: 'Bags', url: '/search/bags' }] },
+]
+
+/** The design's collection that stands for `schema`, if it has one (getStorefront lists them with their schema). */
+async function collectionFor(schema) {
+ const store = await popcraft.getStorefront()
+ return store.collections.find(c => c.schema === schema) ?? null
+}
+
+/** Replace a collection's records with `rows` (values by field key). */
+async function replaceRecords(collection, rows) {
+ const have = /** @type {{ records?: { id: string }[] }} */ (await popcraft.getRecords({ collection: collection.id }))
+ const ids = (have?.records ?? []).map(r => r.id)
+ await popcraft.editRecords({ collection: collection.id, remove: ids, upsert: rows.map(values => ({ values })) })
+}
+
+/** Make the store's collections the design is missing, each with samples (or the connected store's products). */
+async function makeStore(store) {
+ const made = []
+ for (const c of STORE_COLLECTIONS) {
+ if (await collectionFor(c.schema)) continue
+ await popcraft.editCollection({ name: c.name, schema: c.schema })
+ const col = await collectionFor(c.schema)
+ let rows = c.samples
+ if (store && c.schema === 'commerce.product') rows = (await products(store, { first: 6 })).map(productValues)
+ if (store && c.schema === 'commerce.collection') rows = (await categories(store)).slice(0, 6).map(x => ({ title: x.title, handle: x.handle, url: `/search/${x.handle}`, description: x.description }))
+ if (col && rows.length) await replaceRecords(col, rows)
+ made.push(c.name)
+ }
+ return made
+}
+
+// ─── What the design still needs to sell ─────────────────────────────────────────────────────────────────────────
+
+const layerOf = id => String(id).split('~')[0]
+/** Each storefront form in a page's HTML: its layer, what it does, and the names it sends. */
+function formsIn(html) {
+ const out = []
+ const re = /