12 min read
Shopify theme import and the Liquid engine
Merchants upload the theme zip Shopify emails them (Online Store → Themes → Import Shopify
theme). The rule the owner set: copy the look exactly, run every function on our own
features, and say in plain words what changed. One owner module does all of it:
packages/core/src/theme-import/ (server entry index.ts, browser-safe half
@commerce/core/theme-import → client.ts).
Pipeline
| Step | File | What it does |
|---|---|---|
| Read the zip | archive.ts | Reads the zip’s own directory, then inflates each entry with fflate’s streaming Inflate 16 KB at a time and stops as soon as it passes the size the entry declared (a “zip bomb” that says 1 KB and inflates to gigabytes costs one small step, security audit 2026-10-06): 50 MB zip, 120 MB unpacked, 15 MB per file, 4,000 files. Paths with .., absolute paths, drive letters or NUL are dropped, not cleaned. Only theme folders and known file types are kept; everything else is listed as skipped. Nothing is executed. |
| Store | import.ts | A new unpublished theme “<name> (imported)” (the live theme is never touched). Every file goes to theme_file (text as UTF-8, binary as base64, with a checksum). theme_import keeps the theme’s settings (settings_data.json current), theme_info and the report. Migration 0085_shopify_theme_import, both tables under RLS. |
| Copy media | import-media.ts | The address is the typed one, else the connected Shopify importer store (shopify_import_connection). Before any download, findImportedMedia reuses files the importer already copied: shopify_import_mapping rows of resource media (image CDN address, matched by file name) and file_name (the Shopify file name, written by the importer for videos from now on; re-run the importer once to fill it for older imports). Only what is still missing is downloaded. shopify://shop_images/x → https://<host>/cdn/shop/files/x through @commerce/core/safe-fetch (public addresses only, redirects re-checked, bytes capped); oversized originals are asked for again at 3000, 2000 and 1400 px. Video names are compared loosely (fileNameKey: case, spaces, _/- and percent-encoding ignored), and a hit also brings the poster (file_poster) and the phone-sized mp4 (file_mobile) the importer copied beside it. A Shopify Files video that the importer has not copied has no address derivable from its name, so the live home page is read once and the n-th video setting of a section is matched to the n-th <video> inside #shopify-section-…__<key>; the main file is the <source> without a media query, the phone one the <source media=…>, the poster poster or data-poster. Copies land in storage and Files (media rows). One import downloads at most 300 images and 20 videos in 3 minutes (THEME_MEDIA_LIMITS); the rest is listed in the report as not copied. The live page is read with forward-only string searches (never a pattern that can backtrack over it). The import counts against the store’s heavy('import') budget. |
| Templates | import.ts, fields.ts | templates/index.json → our home, sections/header-group.json → header, footer-group.json → footer, and product, collection and page with their alternates (page.about.json → page about, importedPages; a suffix our templates cannot hold is skipped). In those three the theme’s first main section (main-*) becomes our own (MainProduct, MainCollection, MainPage) and any further main section is dropped; a product or collection template without one gets ours first, so the buy box is never missing. On those pages the page context loads Shopify’s product, collection or page global from the path (resourceHandle in page-context.ts), so the theme’s sections around ours (specs, size guide, related products) read it. Each section becomes one Puck item of type LiquidSection: sectionType, sectionKey, _name (the schema name, shown in Customize), its settings at the top level (settingPropKey prefixes reserved ids) and blocks: [{ id, type, disabled, settings }]. Image settings are stored as { url, alt, width, height }, Shopify links as our paths, handles as handles. |
| Report | import-report.ts, report.ts, swap.ts | Every section file: where it is placed, what it relies on (swap table), app blocks, main templates, LiquidJS parse errors → imported, replaced, unavailable, unsupported. Plus menus, collections, products and blogs the imported pages name that this store lacks, images and videos copied or not, skipped files. Copy (English and Hindi) lives in apps/admin/src/data/theme-import.ts keyed by reason. |
The engine
engine.ts creates one LiquidJS (MIT) instance per theme version: snippets are served from
memory (templates option, no file system), ownPropertyOnly, and render limits
(LIQUID_LIMITS, applied by renderBounded): renders are synchronous, so while one runs the
whole server waits, for every store on it. One render pass may take 2 s of CPU and write 4 M
characters; all renders of one page context together (layout, sections, passes, the canvas’s
60-section batch) 6 s; then the rest render as a comment. Parse and memory caps as before.
Shopify-only tags:
| Tag | Here |
|---|---|
form | Draws the theme’s form; posts to /theme-actions/<action> (cart-add, newsletter, contact, localization) or our pages (login, account). Inner form object reads like Shopify’s, posted_successfully? from ?form_posted= after our handler redirects back. |
style, stylesheet, javascript | Inline <style> / <script> (with the request’s nonce on the storefront). |
commerce_script_nonce | Ours. bundle.ts (nonceThemeScripts) writes it after every <script in the theme’s source markup (never inside {% %}, {{ }} or {% raw %}), and it prints nonce="…". A <script> that arrives in data (a product title, an entry, a setting) is output, not source, so it gets no nonce and the CSP blocks it. |
section, sections | Static sections render in place; groups leave markers in the layout the storefront fills with its own header and footer templates. |
content_for 'blocks' / 'block' | Theme blocks from blocks/*.liquid. |
paginate | One page (collections hand themes up to 50 products), with the paginate object. |
schema, doc, layout | Ignored at render. |
Filters (filters.ts, values in filter-values.ts): t (locale files, plurals, interpolation,
visible “Translation missing”), asset_url (versioned /theme-assets/<theme>/<file>?v=<checksum>),
image_url / image_tag (sizes kept from the image, so width/height match), img_url,
stylesheet_tag, script_tag, preload_tag, money and friends (INR through @commerce/money,
or a Shopify money_format when one is configured), placeholder_svg_tag, json (escapes <),
font_face / font_url / font_modify (families loaded from Google Fonts by the layout),
colour maths (color_lighten, color_mix, color_contrast…), link and URL helpers,
time_tag, date with Shopify’s named formats, payment_type_svg_tag (our own simple marks),
file_url (our stored URL when the Shopify importer copied the file, matched by loose name; the old store’s address only when not; read only for themes that call it), video_tag and media_tag for videos (every mp4 rendition as a <source>, the smaller one with media="(max-width: 749px)", the poster, autoplay/loop/muted/controls), external_video_tag. A video setting reaches Liquid as a VideoDrop with sources (1080 and, when copied, 720 high; themes like Zero Drift pick by source.height) and preview_image; the Customize field is an object (url, mobileUrl, poster.url). Unknown filters pass their input through.
Objects on our data
drops.ts, resource-drops.ts and page-context.ts map our records onto Shopify’s shapes:
shop, settings (typed by settings_schema.json: fonts, colours, colour schemes, images),
section / block, product / variant (prices in paise as Shopify’s subunits; ids are
whole numbers from the UUID v7, numeric-id.ts, because themes write
"id": {{ variant.id }} into JSON; /theme-actions/cart-add turns them back with
resolveThemeVariantId), collection(s), all_products, linklists, blogs, pages, cart,
routes (our paths), request, localization (India, INR), template. Resources read as their
handle when used as a key (collections[section.settings.collection]). Our built-in pre-order
shows up as a selling plan group named “Pre-order”, which is how pre-order apps mark products on
Shopify. Menu links carry link.object (the collection, product, page or blog of their path). Metafields and metaobjects read our custom data, see below.
Metafields and metaobjects
The Shopify importer copies metafields into our extra fields (product and collection owners)
and metaobjects into content types and entries, storing Shopify’s namespace and key (and
metaobject types) through keyFromName (lower snake case). custom-drops.ts maps them back the
same way, so product.metafields.custom['size-guide'] finds custom.size_guide.
| Liquid | Here |
|---|---|
product.metafields.<ns>.<key>, collection.metafields… | A metafield: .value, .type (Shopify’s name, list. for lists), .list?; printed on its own like Shopify prints it (text as text, references as gid://shopify/…, lists and objects as JSON). Other properties read through to the value (feature.image.width). |
.value by type | Text, number, boolean, date and URL as is; colour → colour object; rich text → HTML; money → { amount, currency } that money formats; rating → { rating, scale_min, scale_max }; measurement → { value, unit } with type weight / dimension / volume; link → { text, url }; file → image (works with image_url) or { url, media_type: 'generic_file' }; product, collection, page, variant, blog post and entry links → the same drops themes get everywhere; group → rows (json); lists → arrays. |
metaobjects.<type> and shop.metaobjects.<type> | .values (published entries, oldest first, up to 250), .values_count, [handle]; each entry exposes its fields (entry.title.value) and system.id / handle / type. |
variant, page, article, blog, shop metafields | Blank: we keep no extra fields on those owners yet (the importer only counts them). |
Only definitions with storefront access, active entries and published targets are read, under the
store’s RLS. Loading goes through the request cache: every owner asked for in a pass is one
query per owner type (custom-data.ts, loadMetafields), each content type one query, and entry
links are followed three levels deep in the same load; files, products, collections, pages,
posts and variants they point at are one query per kind.
Synchronous passes
LiquidJS’s async mode creates a promise per value; React’s development server tracks every
promise in a Server Component, which made a home page take 20 s. Renders are therefore
synchronous: cache.ts answers from memory and notes anything not loaded, the section is
rendered again after one batched load, up to five passes (render.ts, renderUntilComplete).
Products (all_products[...], product pickers) and collection products asked for in one pass load
in one batch (products, collectionsProducts on the data source). A home page is about 1 s in
dev and two passes per section.
Cached renders
The storefront keeps every rendered section and the layout parts per store version, and the
theme data Liquid reads per store version too: a page whose sections are all cached runs no
Liquid at all. A save, a stock change or a theme publish drops them at once (a Postgres trigger
and LISTEN). The cart, the clock and the CSP nonce are handled so one render serves everyone it
should: see Storefront performance → Caching.
Where it renders
- Storefront.
apps/storefront/src/lib/liquid-theme.ts: when the request’s theme (live, or a verified preview) has atheme_importrow,renderTemplatetakes everyLiquidSectionfrom the render cache or renders the missing ones (renderLiquidSections, one transaction) and theLiquidSectionrenderer places it inside Shopify’s own wrapper (<div id="shopify-section-template--…__hero" class="shopify-section …">, ids shaped like Shopify’s because themes key CSS on them). The store layout draws the theme’slayout/theme.liquidhead (stylesheets, CSS variables), the body markup before the header group and after the footer group (icon sprites, cart drawer, scripts), with the request’s CSP nonce on every<script>the theme’s own source writes (seecommerce_script_nonceabove). Our own pages (product, cart, search, account) render inside the theme’s header and footer. The checkout (/checkoutand everything under it) never does: the layout drops the theme’s shell, header and footer there (liquidLayoutForPath,lib/checkout-path.ts), so the theme’s CSS cannot restyle it and its scripts cannot rewrite it before React hydrates (Zero Drift’sreveal.jswraps everymain h1–h3in a span, which threw hydration error #418). Assets:/theme-assets/<theme>/<file>(route-s/[storeId]/theme-assets/[themeId]/[file]), cached for a year when?v=matches, CORS-open for the canvas, SVG sandboxed. - Customize.
themes.import.editorreturns schemas, the schema locale, colour schemes and the theme’s head;use-liquid-theme.tsturns each{% schema %}into Puck fields (liquidSectionFields: text, textarea/richtext, number/range, select/radio, checkbox, image, colour, colour scheme, product/collection/blog/page/menu handles) passed throughmetadata.liquid;LiquidSection.resolveFieldspicks the section’s own. Blocks are one array field whose item fields depend on the block type (customize-liquid-blocks.ts). The canvas asksthemes.import.renderfor HTML (batched,liquid-canvas.ts) with everything that can run removed bysanitize-html(canvasSafeHtml,@commerce/blocks/canvas-html: scripts, frames,<meta>/<base>, everyon*handler however quoted, script URLs) — the canvas shares the admin’s origin. The iframe gets the theme’s head instead of our theme CSS (preview-frame.tsx).
Behaviour swap
The theme’s JavaScript runs on the storefront for its look (sliders, reveals, marquees). Calls it
makes to Shopify go to our features instead (apps/storefront/src/lib/liquid-shim.ts, inline
before any theme script). This is not an emulation of Shopify’s API; each call becomes one of ours.
| Shopify | Here |
|---|---|
Add to cart forms, /cart/add(.js) | /theme-actions/cart-add → our cart (addLineAction), then the cart page |
/cart.js | /theme-actions/cart: count and total from our cart |
/cart/change.js, /cart/update.js, /cart/clear.js, cart drawer refresh | Our cart page |
Section rendering (?section_id=, ?sections=) | /theme-actions/section: the theme’s own section rendered by our engine with the collection or product of the path (lazy tabs and carousels) |
/search/suggest | An empty answer; the search box submits to our search page |
/recommendations/products (product_id, limit, section_id) | /theme-actions/recommendations: the theme’s own section rendered with recommendations.products filled from relatedProductIds (theme-import/related.ts): products sharing a collection first, then the same product type, then shared tags; never the product itself, only active products with a sellable variant, store-scoped by RLS. The numeric product_id the theme saw maps back to our UUID like variant ids do |
Shopify.customerPrivacy (cookie banner) | Our consent cookie: the theme’s banner asks when we would, its answer goes to ConsentManager (commerce:consent event) which stores it and loads the allowed tags |
Newsletter form (form 'customer') | /theme-actions/newsletter → subscribeAction (double opt-in) |
Contact and advice forms (form 'contact') | The Forms app (packages/plugin-forms). /theme-actions/contact reads contact[...] fields, drops a post whose honeypot field contact_website is filled, rate-limits per IP and store (5 per 10 minutes), saves a contact_message row (RLS table, store from the hostname never the body) and queues contact.message_received in the outbox; the worker emails the store contact address (contact_message template). The page comes back with ?form_posted=contact (form.posted_successfully?) or contact_error (form.errors). Without the app installed the tag draws “This form is not available right now” instead of a form |
| Checkout | Ours, never themed: no theme CSS, scripts, header or footer on /checkout/* |
| Customer account forms | Our account pages |
App blocks (@app) | Left out; listed in the report |
App data: product.metafields.reviews.rating / .rating_count, shop.metafields.judgeme.* (Judge.me’s reviews_grid, all_reviews_rating, all_reviews_count, trust_badge.verified_reviews_count) | The Reviews app (packages/plugin-reviews). The storefront passes reviewsLiquidSource as storeLiquidData’s reviews option when the app is installed: product metafields get our published average (a rating value out of 5) and count (withReviewMetafields, replacing a rating the importer copied when we have reviews of our own; none with “stars on product cards” off), and shop.metafields.judgeme (ShopMetafieldsDrop) returns the newest 25 published reviews (featured first) in Judge.me’s shape, loaded once per request through the data cache. Without the app those keys stay blank, so the sections stay hidden |
| Metafields, metaobjects | Our extra fields (products, collections) and content types; app namespaces stay blank until an app fills them |
| Pre-order apps | Our pre-order as a “Pre-order” selling plan group |
The same table, in code, is swap.ts (SWAP_TABLE), which also detects each row in a section’s
Liquid for the report.
Theme scripts wait for React
An imported page is both the theme’s HTML and a React tree (our swapped-in sections, the
layout). The theme’s defer, async and module scripts are parked as inert
<script type="text/x-theme-deferred"> placeholders when the Liquid output is placed
(apps/storefront/src/lib/liquid-defer.ts, for sections and the layout’s head, before-header
and after-footer parts) and run by the shim’s window.__commerceBootTheme() once React has
hydrated (components/theme/liquid-boot.tsx: after the layout’s effects and the end of parsing),
in document order, each external file once. Classic inline scripts (the js class, settings
JSON) still run where they are.
Why: a theme script that ran first edited markup React was about to hydrate (Zero Drift’s lens
zoom adds a span to .product-gallery__main, a class our own product section shares). React then
rebuilt the page on the client, every Liquid section came back through innerHTML, its scripts
never ran again and everything the theme had bound was gone (meteors, reveals, rotating words).
The shim also replays the common media="print" onload="this.media='all'" stylesheet pattern,
since our CSP blocks inline event handlers.
Tests
packages/core/src/theme-import/engine.test.ts (no database) and import.test.ts (database,
fake fetch and DNS, MemoryStorageProvider) run on the fixture theme in
packages/core/test/theme-import-fixture.ts. No test touches the network or the owner’s theme.
Not yet
- Product, collection and other resource templates from the theme (they use our pages today).
- Editing the theme’s own settings (
settings_schema.json) in Customize; adding new sections from the theme in the Add panel. - A cart drawer that refreshes in place, predictive search results in the theme’s look.