@debutify/liquidjs
v1.6.0
Published
Debutify's fork of LiquidJS with Shopify Liquid semantics, for rendering Shopify themes outside Shopify.
Maintainers
Readme
@debutify/liquidjs
Debutify's fork of LiquidJS for rendering Shopify themes outside Shopify. With default options it is LiquidJS (see NOTICE.md). Install with npm install @debutify/liquidjs. It runs on Node.js 24 or newer; the browser bundles of the root entry are unchanged. Besides the root entry it has two Node.js entries: @debutify/liquidjs/shopify, which renders Shopify themes, and @debutify/liquidjs/storefront, which serves a whole store as a function from a Request to a Response.
Options added by this fork
| Option | Default | Effect |
| --- | --- | --- |
| numberSemantics | 'js' | 'ruby' keeps whole numbers and decimals apart as Shopify Liquid does ({{ 10 \| divided_by: 4 }} is 2, {{ 2.0 }} prints 2.0, {{ 2.0 \| json }} is 2.0 where the runtime has JSON.rawJSON, decimal arithmetic is exact). |
| forloopParentloop | false | true gives forloop a parentloop, the forloop of the for loop it is nested in (nil at the outermost loop and in a rendered partial, which has a context of its own), as Ruby Liquid does; a loop in an include sees the loop around the include. With false forloop.parentloop is undefined, as in upstream LiquidJS. The Shopify preset (createShopifyLiquid) sets it to true. |
| rawTags | ['raw'] | Tags whose bodies are never parsed; register a RawBodyTag subclass for each new name. A listed comment nests. The list replaces the default, so include raw to keep {% raw %} raw. |
| inlineErrors | false | Write render errors inline (Liquid error (line 1): …) and keep rendering; collect them with render(tpl, scope, { errors }). |
| topLevelSize | true | false makes an undefined top-level size print nothing, as Ruby Liquid does, instead of the number of keys in the render's globals as upstream LiquidJS does; a size that a scope or the globals define, and the size of a value (list.size), are not affected. The Shopify preset sets it to false. |
| bugCompatibleWhitespaceTrimming | false | true trims as Ruby Liquid's bug_compatible_whitespace_trimming parse option, which Shopify's storefront parses themes with: when {%- or {{- strips the text before it to nothing, the text keeps its first character, so {{ a }}\n {%- if b %} keeps the newline. The Shopify preset sets it to true. |
const { Liquid } = require('@debutify/liquidjs')
const liquid = new Liquid({ numberSemantics: 'ruby', rawTags: ['raw', 'comment'], inlineErrors: true })
const errors = []
liquid.parseAndRender('{{ 10 | divided_by: 4 }} {{ 2.0 }} {{ 1 | divided_by: 0 }}', {}, { errors }).then(html => {
console.log(html) // 2 2.0 Liquid error (line 1): divided by 0
console.log(errors.length) // 1
})Details that do not fit the table:
- With
numberSemantics: 'ruby', decimal values that reach API callers (evalValue,evalValueSync, values written byassign) areLiquidFloatobjects: read.value, or useNumber(x). - A
rawTagslist replaces the default['raw']instead of adding to it. Withoutrawin it, the body of{% raw %}is parsed as Liquid, sorawTags: ['comment']makes{% raw %}{{ x {% endraw %}a tokenization error. - Setting
rawTagsexplicitly, even to the default list, lets an end tag carry a whitespace-control marker in the forms{%- endraw -%},{% endraw-%}and{%- endraw %}. Under the default list those three stay unclosed, as in upstream LiquidJS, while{% endraw -%}closes either way. - With
inlineErrors, an error in a tag that prints nothing (assign,capture, a block with a blank body) is recorded inerrorsand not written. - With
inlineErrors, an undefined variable is recorded and not written, but it is an error only understrictVariables; by default nothing is recorded. - With
inlineErrors, an undefined filter is recorded and not written only when it is met while rendering, for example inside a grouped expression (groupedExpressions). UnderstrictFiltersa misspelt filter in an ordinary tag is a parse error and still throws, as in upstream LiquidJS, and withoutstrictFiltersit is not an error at all. - With
inlineErrors, a syntax error met while rendering, such as in a partial parsed at render time, is written asLiquid syntax error (<file> line <n>): <message>. A syntax error in the template you pass toparsestill throws, and so does a limit error (memoryLimit,renderLimit,parseLimit). A limit hit while rendering arrives wrapped in aRenderError, so test what you catch withisLimitError(err), which looks throughoriginalError;LimitError.is(err)is false for the wrapper. - With
topLevelSize: false,{{ size }}prints nothing,{% if size %}is false and{{ size | default: 'M' }}printsMwhen nothing definessize. UnderstrictVariablesan undefinedsizethrows like any other undefined variable. - With
bugCompatibleWhitespaceTrimming: true, a text that a-%}or-}}already stripped to nothing keeps nothing, a text with anything but whitespace in it is stripped as before, and the body of a raw tag is never trimmed.
All six defaults are upstream LiquidJS's behaviour, so none of these options changes anything until you opt in.
One behaviour differs from upstream without an opt-in: the slice filter charges memoryLimit for the length of the array or string it returns, where upstream LiquidJS 10.30.0 charges the length it was asked for, so {{ list | slice: 0, 1000 }} over a short array no longer hits a low memoryLimit. It only matters once you set memoryLimit, which is unlimited by default; the Shopify preset sets it.
Upgrading from 1.5
- The Shopify preset refuses an output or a tag Shopify's tokenizer cannot close. Ruby Liquid ends an output at its first
}and a tag at its first%}, whatever quotes surround them, so Shopify refuses a theme file on upload when an output holds a}in a string ({{ 'flag-us.svg' | replace: 'flag-us.svg', 'flag-{code}.svg' }}isVariable '{{ 'flag-us.svg' | replace: 'flag-us.svg', 'flag-{code}' was not properly terminated with regexp: /\}\}/); 1.5.0 rendered it. Under the preset an unclosed output isVariable '{{' was not properly terminated…, an unclosed tagTag '{%' was not properly terminated with regexp: /\%\}/, and a string that never closesUnexpected character ', where 1.5.0 saidoutput … not closedor accepted the string. The root engine keeps upstream LiquidJS's reading unlessrubyTokenizer: trueis set. To do: a theme whose output holds a}in a string must move it out of the output. Shopify.loadFeaturesloads Shopify's feature scripts. A registered feature (model-viewer0.6 to 1.12,model-viewer-ui1.0,shopify-xr1.0,video-ui1.0, 1.1 and 2.0,consent-tracking-api0.1 and 0.2) loadshttps://cdn.shopify.com/shopifycloud/<folder>/v<version>/<file>[.<locale>].jsas a module script, as Shopify'sload_feature-<hash>.jsdoes (the engine reads the same mapping, so a new hash does not stale it), andonLoadand the callback get no error once it ran; 1.5.0 answeredload error: … is not available in the previewand never loaded one, so a product's 3D model and the video player never started.<model-viewer data-shopify-feature="…">elements load their version on their own, at load and when a theme adds them, andShopify.loadFeaturesanswers an unregistered feature at once, as the loader does, not after the current task. The preview host must let those scripts load fromcdn.shopify.com.- A sandboxed preview prints a video or a 3D model at its CDN address. Liquid prints these files under the store (
//<store domain>/cdn/shop/3d/models/o/<id>/box.glb?v=0); Shopify's store domain answers that path with the file's bytes (no redirect,access-control-allow-origin: *) fromhttps://cdn.shopify.com/3d/models/o/<id>/box.glb, but a preview is served from another address, so the model never loaded. Withsandbox: truethe storefront prints the data's address,https://cdn.shopify.com/3d/models/o/<id>/box.glb(andhttps://cdn.shopify.com/videos/c/vp/<id>/<file>), on every page and Ajax section; a store served from its own domain prints what it did. New optionalstoreFiles: 'cdn'onRequestInputandStoreViewdoes this for any caller.
Upgrading from 1.4
Every change is additive in the types: each new field is optional, and a store that leaves it out behaves as under 1.4.0, except where a bullet says what changed. The behaviours that change for a store as it was are the search (a query with several words, the syntax of a query, the escaped search.terms), the filter links (a space is +), the prices and answers of a variant with quantity rules or price breaks, the sandbox policy (it gains allow-popups and allow-popups-to-escape-sandbox, so a host must send and frame with the same four tokens, allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox), the product form's payment_button (Shopify's <shopify-accelerated-checkout> markup in place of a disabled button, with its scripts and links in content_for_header and a module the host serves under /cdn/shopifycloud/), /checkout and /account (a 302, not the notice page), the contact form's redirect (no fragment), assign of blank or empty (it holds the empty string, which equals both, on the root entry too), image_url (it adds crop=center when it has both a width and a height), json (it refuses the objects Shopify refuses and prints a media and its preview_image as the parity store does), the media tags, routes.account_login_url, window.Shopify (it gains Shopify's own fields and loses designMode), country_option_tags, the ids ?sections= takes, and the sample stores. The list of new fields and exports is the last group.
Engine
assignholds the empty string forblankandempty. Ruby Liquid reads the literalsblankandemptyas''outside a condition (Expression::LITERALS), so after{% assign x = blank %}(orempty),{% if x == blank %}and{% if x == empty %}run their branch and{{ x }}prints nothing, while the literal{% if blank == blank %}stays false (liquid-specComparing blank and blank resolves to false). The parity store's product page relies on it: the theme's review snippet starts fromblank, testsrating == blankand printsaggregateRatingonly when a rating replaced it (scenariopage-product-unit-price). 1.4.0, like upstream LiquidJS, held a blank object, which equals no value, so that branch never ran.
Search
- A search query reads Shopify's syntax.
field:value(title,body,tag,vendor,product_type,id,variants.title,variants.skuandvariants.barcodefor a product;title,bodyandauthorfor a page; those andtagfor an article), a quoted phrase,-termandNOT term,OR,AND, parentheses. Horizon's recently viewed products ask forid:123 OR id:456; 1.4.0 split that into words and found nothing. The meaning of each is what the parity store answers (fixturesprobe-search-q-*, 34 recorded queries): a field narrows,ORjoins,ANDand a space meet, a negation takes the matches out, an unknown field and a wildcard are ignored, and the plain words beside a field or a negation only rank. - A query of plain words finds what holds any of them. The parity store finds the products with any word of
gray sneakers, five of them, where the help centre says terms are joined with AND and 1.4.0 required all; the ones with more of the words come first. A lowercaseandororamong other words is not searched. search.termsis HTML-escaped, as the parity store prints it ("gray leather"), and the filter links write the query as Shopify does: a space is+, and! ' ( ) *are percent-encoded.id:matches pages and articles by their own id, and the search page's filter links leaveresources[...]out. A product's id finds no page or article (fixtureprobe-search-w-id-all), andresources[type]andresources[limit], which only the predictive search reads, no longer appear inurl_to_addandurl_to_remove(fixtureprobe-search-w-resources-type).
Objects
link.objectis the object a menu link points at. The collection, page, blog, article, policy or product of the link's address, which Horizon's mega menu reads for its images and featured products; 1.4.0 held nil. A link to the catalogue, the list of collections, a product type or vendor or another site has none.- Blog comments.
BlogData.commentable(no,moderateoryes, as the Admin API has it) setsblog.comments_enabled?andblog.moderated?, andArticleData.commentsthe published comments.article.comments,comments_count,comments_enabled?,moderated?andcomment_post_urlfollow, and acommentobject hasauthor,content,created_at,email,id,status,updated_atandurl. 1.4.0 had none of it. - A cart line can be nested, and a variant's quantity rule and price breaks are held.
CartLineState.parentKeynests a line under another;line_item.parent_relationship.parentis the parent line item (Dawn and Horizon mark and label the nested line from it), and the Ajax answers printparent_relationshipandinstructionsfor it. A line under a quantity price break is priced at the highest break its variant's quantity in the cart reaches. - A product can be unlisted, and a variant can be untaxed.
ProductData.unlisted(boolean, false when omitted) is Shopify's status Unlisted (https://help.shopify.com/en/manual/products/details/product-status): the product opens at its address and is found byall_products, by its id and by its variants in the cart, and it is left out of every list of products,collections.allwith itsproducts_count,typesandvendorsamong them.VariantData.taxable(boolean, true when omitted) setsvariant.taxable(https://shopify.dev/docs/api/liquid/objects/variant#variant-taxable), false for a gift card's variants. 1.4.0 had neither field, so every product was listed and every variant taxable. The page of an unlisted product startscontent_for_headerwith<meta name="robots" content="noindex,nofollow">(fixturepage-product-video), and/products/<handle>.jsleaves out thefeatured_mediaof a variant that has no image where 1.4.0 printednull(fixtureajax-product-js-media). To do: nothing. - A 3D model is a media.
ModelMediaData(media_type: 'model',id,alt,preview_image,sources) inProductData.media, each source aModelSourceData(url,mime_type,format), givesproduct.mediaa model withsourcesand noaspect_ratio(https://shopify.dev/docs/api/liquid/objects/model, https://shopify.dev/docs/api/liquid/objects/model_source). 1.4.0'sMediaDataheld images and videos only, so a theme met a model only in data a caller supplied, andmedia_tagprinted nothing for it. A source on Shopify's CDN (https://cdn.shopify.com/3d/models/…) reads//<shop domain>/cdn/shop/3d/models/…?v=0in Liquid and stays as it is in the Ajax answer, where a model has only its sources (fixtureajax-product-js-media, scenariopage-product-model). To do: nothing. - A hosted video and an external video read as the parity store prints them. A video's
sourceson Shopify's CDN (https://cdn.shopify.com/videos/…) read//<shop domain>/cdn/shop/videos/…?v=0in Liquid with the HLS playlist (m3u8) listed first, and stay as they are with the playlist last in the Ajax answer (fixtureajax-product-js-media, scenariopage-product-video).aspect_ratiois rounded to three places (1.778), whilepreview_image.aspect_ratiostays whole (--preview-ratio: 1.7777777777777777), and thepreview_imagehas the media'salt(<img alt="The tote in a short clip">). 1.4.0 kept the data's addresses, order and ratio, and gave the preview its ownalt. - Unit prices and ratings reach the product answers. A variant with a
unit_price_measurementprintsunit_priceand the measurement in/products/<handle>.jsand inproduct | json, withquantity_valueas text with a decimal ("250.0"), afterquantity_rulein the first and afterrequires_selling_planin the second; 1.4.0 left both out. Aratingmetafield'srating,scale_minandscale_maxprint as text injson({{ rating.value.rating | json }}is"4.5","bestRating"is"5.0") and as numbers everywhere else (scenariopage-product-unit-price, fixtureprobe-product-extras-unit-price). To do: nothing. routes.account_login_urlis the customer login on the request's own origin.https://<host>/customer_authentication/redirect?locale=fr®ion_country=CA, under the base path and with no locale prefix,localethe page's language andregion_countrythe shopper's country (fixturesprobe-page-home,probe-page-fr-caandprobe-page-en-eu); 1.4.0 printed<prefix>/account/login. The storefront answers the address with the notice page.country_option_tagsandall_country_option_tagsprint as the parity store does. The name sits in a double-quoted attribute with&,<,>and"escaped and an apostrophe left (Côte d'Ivoire), the subregions are written as Ruby'sto_jsonwrites them (&,<and>as&,<and>) and then escaped, apostrophe included (O'Higgins), and the label has only<and>escaped (Antigua & Barbuda).CountryOptionData.labelis the text the option shows when it is not the name (Åland Islandsfor the nameAland Islands). 1.4.0 escaped the name and the label alike and had no label (scenarioajax-cart-lines, the cart's shipping calculator).collections.alllists the unlisted products' tags, types and vendors. On the parity store,all_tags,all_typesandall_vendorsofcollections.allcount the unlisted products, while itsproducts,products_count,tagsand filter values leave them out, and no other collection counts them (fixturesprobe-collection-*). Thevendorsandtypescollections keepall_typesin the store's order. 1.4.0 left unlisted products out of every list.
Filters
payment_buttonprints Shopify's accelerated checkout. The<div data-shopify="payment-button">around a<shopify-accelerated-checkout>element whose attributes carry the buyer's country, language and currency, a{id, requiresShipping}for every variant, the shop'saccess-tokenandshop-id, and a skeleton, with the buyer consent text after it for a product sold with a selling plan (English only), as the parity store prints it on the product pages of Horizon 4.3.0 and Dawn 16.0.0, recorded identically (https://shopify.dev/docs/api/liquid/filters/payment_button, scenariospage-product*). The token and id are a stable pair made from the store's domain, not Shopify's (accepted partaccelerated-checkout-store-values). A form that is not a product form prints nothing. 1.4.0 printed a disabled "Buy it now" button of its own.additional_checkout_buttonsstays false andcontent_for_additional_checkout_buttonsempty, as the parity store's cart pages report them (recordingsajax-cart-lines,ajax-cart-lines-dawnandajax-cart-lines-horizon). To do: see thecontent_for_headerbullet under Pages for the files the markup loads.image_urlcrops from the centre when it has a width and a height.image_url: width: 550, height: 550, and400by225,300by100and400by400, printcrop=center, on an image and on the preview of a video or a model, and so does Horizon'smedia | image_url: width: 550, height: 550; a width or a height alone prints nocrop, and acroporpad_colorthe template names is kept (https://shopify.dev/docs/api/liquid/filters/image_url, scenarioprobe-product-video-media). 1.4.0 added nocrop. To do: nothing.video_tagwrites a video as the parity store does. The template's attributes come in the order it wrote them; apreloadit names replaces the defaultpreload="metadata"in its place;autoplaywithoutmutedaddsmutedafter thepreload; only the files that are not an HLS playlist are written as<source>(a video that has only a playlist still writes it); and the<img>fallback has the media's alt text, then itssrc(https://shopify.dev/docs/api/liquid/filters/video_tag, scenariosprobe-product-video-mediaandpage-product-video). 1.4.0 always wrotepreload="metadata"first, skipped a playlist only underloop, and left the fallback without analt.media_tagprints an image and a 3D model. An image is an<img>with the template's attributes in the order written, thenaltand thesrcof the legacyimage_sizefile (smallby default,_400xfor400x), with nosrcset,widthorheight(scenarioprobe-product-video-media); 1.4.0 printedimage_tagat the image's own width. A model prints its<model-viewer>(https://shopify.dev/docs/api/liquid/filters/media_tag, scenariopage-product-model); 1.4.0 printed nothing and reported it as unsupported.image_size: 'master'names the picture as it was uploaded and adds nothing to its file name, invideo_tag's poster,model_viewer_tag's poster and an image'smedia_tag(…thumbnail.0000000000.jpg, wheresmallprints…_small.jpg).model_viewer_tagputs the template's attributes first. An argument of the name of a default moves to the front with the other arguments, in the order written, and the default is not written again;altkeeps the media's alt text (alt: 'A cube'printsalt="The cube in 3D"); a boolean prints as text (toggleable="true",camera-controls="false") (https://shopify.dev/docs/api/liquid/filters/model_viewer_tag, scenariosprobe-product-model-mediaandpage-product-model). 1.4.0 kept each default where it stood and replaced its value.external_video_urlwrites a boolean as1or0.autoplay: trueprintsautoplay=1, where 1.4.0 printedautoplay=true, as the parity store prints it for a hosted or external video.tdoes not escape an HTML-safe value a second time. The key's text and each value are escaped alone, and a value that is HTML-safe already is written as it is.line_item.titleis now such a value, andescapeleaves it alone as well, so the cart'saria-label="Remove Men's T-shirt"is what the parity store prints in all three themes, where 1.4.0 printedMen&#39;s./cart.jsandcart | jsonstill print the title asMen's T-shirt - Small / Green, and the title in the add, change and update answers is plain text (fixtureajax-discount-codes). A key that ends in_htmlis unchanged.standard_event_dataprints an emptied cart and orders a code's keys as Shopify does. A cart that was created and then emptied (/cart/clear.js) prints its zero totals, where a cart nobody has changed still prints"cart":null; a discount code is{"applicable":false,"code":"WELCOME10"}withapplicablefirst (recordingsajax-checkout-redirectsof Horizon andajax-cart-lines-dawn). 1.4.0 printednullfor every cart without lines and wrotecodefirst.jsonprints a media as the Ajax answer does.product.media | jsonand one media's| jsonprint a video, an external video or a 3D model with itsalt,id,position,preview_imageandmedia_typeand its own fields (a video'saspect_ratio,durationandsources, an external video'saspect_ratio,external_idandhost, a model'ssources; each source withformat,height,mime_type,urlandwidth, a model's without the size), a video's playlist last and thepreview_image.aspect_ratiorounded to three places (1.778,1.333;{{ media.preview_image.aspect_ratio }}prints the whole ratio).media.preview_image | jsonis the picture's address, asproduct.images | jsonlists one (fixturesajax-product-js-mediaandprobe-product-video-media, scenariospage-product-modelandprobe-product-model-media). 1.4.0 printed the built media object as it was.jsonrefuses the objects Shopify refuses.{{ x | json }}writes{"error":"json not allowed for this object"}where the object stands, also in a list or inside another object, for a video's or a model'ssources(each source), forproduct.metafieldsand the othermetafieldsobjects (also when empty), for aratingmetafield (and each rating of alist.rating) and for a variant'sunit_price_measurement(scenariosprobe-product-video-media,probe-product-model-media,probe-product-extras-unit-price,probe-product-extras-gift-cardandprobe-product-extras-pickup). 1.4.0 printed all of them: the sources and the measurement as objects, the metafields with their values, a rating as{"rating":"4.5","scale_min":"1.0","scale_max":"5.0"}.product | jsonstill writes a variant's measurement and a media's files itself. To do: a theme that prints one of these throughjsonnow prints the error object, as it does on Shopify.inline_asset_contentof a missing asset prints<!-- inline_asset_content: Asset not found. -->, as the parity store does on Dawn's product page (recordingpage-product-model, https://shopify.dev/docs/api/liquid/filters/inline_asset_content). 1.4.0 printed nothing.
Pages
content_for_headerlinks the endpoints Shopify links and carries the checkout scripts. A product page links its oEmbed (<link rel="alternate" type="application/json+oembed">) and, unless the product is sold only with a selling plan, the cart permalink of the variant it shows (<link rel="buy" href="…/cart/<variant id>:1?ucp/version=2026-08-25" payment="">); a collection links its Atom feed and, unless it isall, its oEmbed; a blog and its articles link the blog's Atom feed (recordingspage-product,page-product-variant,page-product-requires-plan,page-product-en-eu,page-collection,page-collection-filtered,page-blogandpage-article). Every page hasShopify.PaymentButtonwith itsinit, the buyer consent helpers and an observer that loads the wallets module when ashopify-accelerated-checkoutorshopify-accelerated-checkout-cartelement appears; a page that printed apayment_buttonalso has the cleanup helpers, the<script type="module">of the module with itsnomoduletwin, theaccelerated-checkout.csslink and the consent styles, all at/cdn/shopifycloud/portable-wallets/latest/(Horizon 4.3.0 and Dawn 16.0.0, recorded identically). 1.4.0 held none of these links or scripts. The Atom feeds are linked and not served (accepted stepatom-feeds). To do: a caller ofrenderPageserves the two files at the addressShopifyServices.shopifyCloudUrlgives (/cdn/shopifycloud/<path>by default) or lets them 404;createStorefrontserves them.window.Shopifycarries what Shopify's own header sets.Shopify.theme(name,id,schema_name,schema_version,theme_store_idnull,rolemain,handlethe textnull,style),Shopify.cdnHost(<permanent domain>/cdn),Shopify.SignInWithShop.eligiblefalse,Shopify.MCP(disabled, no tools, an empty endpoint) andShopify.featureAssets['shop-js'](an empty map), the last three written, as Shopify writes them, in scripts of their own (window.Shopify = window.Shopify || {};thenwindow.Shopify.<path> = <value>;) after the feature loader and not in the bootstrap script, in the shape of the home page the parity store recorded for the three themes (page-home) with neutral values: a preview has no theme id, serves no MCP endpoint and loads no shop-js module. The theme's name and version come fromtheme_infoinconfig/settings_schema.json, and its id is a number made from them.Shopify.designModeis no longer printed, since Shopify defines it in the theme editor only.Shopify.modules,Shopify.loadFeaturesandShopify.autoloadFeaturesexist and answer as Shopify's feature loader does: a feature it does not register fails withdoes not existand a registered one loads its script from Shopify's CDN (see Upgrading from 1.5); 1.4.0 defined none of them, so a call threw aTypeError. A page that holds a<shopify-account>element defines it and<shopify-store>: a link toroutes.account_login_urlin the shadow structure of Shopify's legacy accounts, withshowModal(),close()and theopenevent Horizon's header listens for (Debutify's stand-in; Shopify'saccount.jsneeds its customer account services). To do: a theme that readsShopify.designModereadsundefined.renderSectiontakesexact.RenderSectionOptions.exact(false when omitted) answers only to the ids the Section Rendering API takes (the idrenderPagereports, a static section's file name or a group's id) and not to a bare key of the JSON template'ssections. The storefront sets it for?sections=and?section_id=: the parity store answers?sections=cart-itemswithnullfor a template'scart-itemssection and renders it fortemplate--<id>__cart-items, where 1.4.0 rendered it for the bare key. A caller ofrenderSectionthat leaves it out behaves as under 1.4.0.
Storefront
POST <article url>/commentstakes a comment. Thenew_commentform posts there. A blog that takes none, or an article it lacks, answers the 404 page; an incomplete comment renders the article again withform.errors; a valid one is kept with the visitor (VisitorState.comments) and redirects to the article with?comment=<id>#comment_form, whereform.posted_successfully?is true. On a blog that publishes comments it joinsarticle.comments; on one that moderates them it does not, and the page'scommenthas the statusunapproved, which the themes read to say it awaits approval. See Routes.add.jstakesparent_idandparent_line_key, and holds the cart to the quantity rules.parent_idnames a variant of the same request,parent_line_keya line the cart holds; a nested line is listed right after its parent and goes with it.add.jsandchange.jsanswer 422 for a quantity under the minimum, over the maximum or off the increment of the variant (counting every line of it), andupdate.jsdoes for a variant not yet in the cart. See The cart./checkoutand/accountanswer a 302, as the parity store does./checkout(GETorPOST) redirects, for a cart with lines and for a visitor with no cart yet, to Shopify's checkout address/checkouts/cn/<cart token>/<language>-<country>?_r=<signature>(the page's language, the cart's country), and for an empty cart to the root of the page's market; the cart form'scheckoutbutton goes to that address as well./accountand every address under it redirect to/customer_authentication/redirect?locale=<language>®ion_country=<country>. The notice page now answers/checkouts/…and/customer_authentication/…with status 200, and thenoticeoption still receives'checkout'or'account'. 1.4.0 answered/checkout,/accountand everything under them with the notice page, and sent the checkout button to/checkout. The cart token,_rand the permalink'scart_link_idare made by the storefront. The empty-cart rule is inferred from one recorded session of seven requests (scenariosajax-checkout-redirectsandajax-cart-form-add; accepted rulescustomer-accountsandcheckout-address-values).GET /cart/<variant id>:<quantity>[,…]is the cart permalink. The cart becomes exactly those lines, newest first, with no stock or quantity check (a quantity of 0 adds nothing), the note and attributes the link names (?note=,?attributes[name]=) and?discount=<code>added to its codes; the answer is a 302 to the checkout address withcart_link_id, or with?storefront=trueto/cart?cart_link_id=…. A variant the store lacks answers 410 with a "Link no longer exists." page (https://shopify.dev/docs/apps/build/checkout/create-cart-permalinks, scenarioajax-checkout-redirects, accepted stepcart-link-gone-page). 1.4.0 answered the 404 page./products/<handle>.oembedand/collections/<handle>.oembedanswer an oEmbed. A product with one offer for each variant, or a collection with its products oldest first, written as Rails writes JSON, asapplication/json+oembed;oembedJson(data, 'product' | 'collection', handle, view?)from/shopifyis its text. A handle the store lacks answers 404 with an empty body. 1.4.0 answered the 404 page for both (scenarioajax-endpoint-links, compared in full)./cdn/shopifycloud/portable-wallets/latest/…serves the module that fills<shopify-accelerated-checkout>.portable-wallets.<language>.jsandaccelerated-checkout.css, under the base path, answered before the visitor's state and cached for a week. The module is Debutify's, since the storefront has no payment provider: one "Buy it now" button (in English, French, Spanish, German, Italian, Portuguese or Dutch) in a closed shadow root, shaped by the--shopify-accelerated-checkout-*custom properties, that goes to the checkout address and never touches the cart. 1.4.0 answered the 404 page.- A valid contact form redirects with no fragment.
POST /contactredirects to the page withcontact_posted=true(customer_posted=true) and no#contact_form; a browser keeps the form's own fragment (/contact#ContactForm) through a redirect that names none (RFC 9110 section 10.2.2), and Shopify's server cannot name the form because a browser never sends one. 1.4.0 added#contact_form. No recording holds Shopify's answer, since its bot challenge answers a scripted post with 403 (accepted rulecontact-bot-challenge). - A recommendations section sees the product it was asked for. A section rendered by
/recommendations/productshasproductset to the product ofproduct_id, as on that product's own page, so Horizon'sdata-product-id="{{ product.id }}"is the id of the element the answer replaces (Horizon reloads its recommendations on any attribute change and throws on an empty id); 1.4.0 leftproductempty there. This is Debutify's reading, which neither shopify.dev nor a recording pins. - The sandbox policy allows popups that escape it. The storefront's own
Content-Security-Policyinsandboxmode issandbox allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox, where 1.4.0 sentsandbox allow-scripts allow-forms, so a link that opens a new tab opens a normal page, as on Shopify. A host must send the same four tokens inContent-Security-Policyon every answer under the base path and in thesandboxattribute of the iframe (see Sandboxed pages);allow-same-originstays off. To do: change both places; a host that keeps the two tokens keeps new tabs blocked. - A
parent_idthat names a cart line but no item of the request answers Shopify's error.add.jsanswers 422Cannot reference existing parent lines by variant_id.(fixtureajax-cart-nested), where aparent_idthat matches neither the request nor the cart keeps its own error.
Sample data
storefrontSampleDataholds the parity store's unlisted products and its full country list.gift-card,video-tote(a hosted video and a YouTube video),display-cube(a 3D model),olive-oil(unit prices and a rating) andpickup-mug(stock at one location, a size sold past zero stock), eachunlisted: true, so every list, search and count is what the parity store prints, and each page opens at its address (scenariospage-product-video,page-product-model,page-product-unit-priceandprobe-product-extras-pickup); their media files are the parity store's own. Itscountry_option_tagsis the 237 countries Shopify lists, with their subregions, where 1.4.0 held four (scenarioajax-cart-lines).sampleStoreDatais unchanged. To do: nothing; a caller that counted the products ofstorefrontSampleDataby listingdata.productsnow counts five more.previewSampleDatahas two products of its own and lists the unlisted ones.wholesale-teeandscented-candleare its own; the five unlisted products above come withunlistedfalse, so/collections/alland search list them. 1.4.0 added four products of its own, among them agift-cardand avideo-tote(a hosted MDN clip and a YouTube video) that are now the parity store's, with the same handles and ids.
Types
- New optional fields and exports.
BlogData.commentable,ArticleData.comments,CommentData,PostedComment,VisitorState.comments,CartLineState.parentKey,RenderPageOptions.comments,RequestInput.comment, andarrangeNestedandquantityRuleOffrom/shopify. - More optional fields and exports.
ProductData.unlisted,VariantData.taxable,CountryOptionData.label,ShopifyServices.shopifyCloudUrl(optional like every key of theservicesoption, which is aPartial),RenderSectionOptions.exact,ModelMediaDataandModelSourceData, andoembedJsonfrom/shopify.MediaDatais nowImageMediaData | VideoMediaData | ExternalVideoMediaData | ModelMediaData, so aswitchovermedia_typethat checks every case needs a'model'case. In the state thatcontent_for_headerreads, which is not exported, the request gainsnoindex(true on the page of an unlisted product) andendpoints(buy,atomandoembed), and the state gainscheckoutButton(apayment_buttonprinted) andtheme({ name, version }fromtheme_info). Nothing a 1.4.0 caller passes is rejected.
Upgrading from 1.3
1.4.0 is a minor release: it adds optional store-data fields, one export (previewSampleData) and one storefront route, and everything 1.3.0 exports is still exported with the same signatures. Every new field is optional and a store that leaves it out behaves as under 1.3.0, except where a bullet below says what changed; the one exception on the root entry (@debutify/liquidjs) is the for fix. This is every change a 1.3.0 caller can see.
Engine
offset: continuestarts where the previous loop ended, also for the first loop. https://shopify.dev/docs/api/liquid/tags/for documentsoffset: continueas starting the loop where the previousforover the same collection stopped; with no previous loop that is the start, as in Ruby Liquid, where the offset isoffsets[name].to_i. 1.3.0 (and upstream LiquidJS) read an empty object as the offset when no loop had run before, which isNaN: the loop started at 0 and stored a string, so every lateroffset: continueover that collection started at 0 as well, and inside an outer loop that repeated every round. This is a fix on the root entry and needs no option. To do: nothing; the first loop is unchanged and the loops after it now continue from it.
Objects
product.gift_card?andline_item.gift_cardfollow the data. https://shopify.dev/docs/api/liquid/objects/product#product-gift_card? and https://shopify.dev/docs/api/liquid/objects/line_item document them as true for a gift card. 1.3.0 had no way to say so and both were always false, so the gift card recipient form and the shipping notes a theme branches on never showed.ProductData.gift_card(boolean, false when omitted) sets it, and a cart line of that product hasgift_cardtrue. A gift card is left out ofrecommendations, as before. To do: setgift_card: trueon the product andrequires_shipping: falseon its variants.- A variant has
quantity_price_breaks_configured?and takes itsquantity_rulefrom the data. https://shopify.dev/docs/api/liquid/objects/variant documentsquantity_price_breaks_configured?as whether the variant has any quantity price breaks; 1.3.0 had it on the product only, so a variant read nil. It is now true whenVariantData.quantity_price_breaksholds at least one break. https://shopify.dev/docs/api/liquid/objects/quantity_rule documents the rule: 1.3.0 gave every variant the default rule (min1, nomax,increment1), so volume pricing and minimum or step quantities could not be previewed.VariantData.quantity_rule({ min?, max?, increment? };minandincrementdefault to 1 andmaxto none) sets it, and a variant that leaves it out still has the default rule. - A country has
popular?. https://shopify.dev/docs/api/liquid/objects/country documents it; 1.3.0 read nil, so a country selector that lists popular countries first listed none. It is onlocalization.countryand on each oflocalization.available_countries, from the new optionalpopularof the entries ofMarketData.countries({ iso_code, name, popular? }), false when omitted. recommendationswithintent=complementarylists the products a merchant set up. https://shopify.dev/docs/storefronts/themes/product-merchandising/recommendations/complementary-products says they are set up by hand in the admin; 1.3.0 modelled no such setup, so the intent always held no products.ProductData.complementary_products(the handles, in the merchant's order; none when omitted) sets them, and they are left out the same way as related products: the product itself, unavailable products, products with a price of 0, gift cards and products in the cart. A product with none set up still has none, as on the parity store (fixtureprobe-recommendations-complementary). This also reachesrecommendationsJsonand/recommendations/products.json?intent=complementary.
Pages
- A
template--<name>__<key>section id renders in the template it names. https://shopify.dev/docs/api/ajax/section-rendering says asection_id"can be used to render a section in the context of any page", and https://shopify.dev/docs/api/ajax/reference/product-recommendations is the request a product page makes withsection_id=template--product__<key>. That request is read as theindextemplate, so 1.3.0 looked the section up there andrenderSectionand/recommendations/productsanswered 404 (the section was not found).renderSectionnow finds the section in the other JSON template of the theme and renders it with that template's settings on the requested page's objects, so a product page's recommendations section loads. The template is found by its id (its name with every character but letters, digits,_and-as-, soproduct.probeisproduct-probe) and the longest matching name wins. An id that names no template of the theme, or no section in it, is not found, as before. To do: nothing.
Storefront
- A sandboxed page keeps its
localStorageandsessionStorageacross page loads. 1.3.0 gave each page empty in-memory storages, so what a theme remembers across pages (recently viewed products, a products-per-row choice, scroll restore) was gone on the next page. A sandboxed page has an opaque origin and no storage of its own (https://html.spec.whatwg.org/multipage/browsers.html#sandboxed-origin-browsing-context-flag), andwindow.namewould be readable by any site the frame is sent to, so the storefront keeps both storages in the visitor's state beside the cart (VisitorState.storage,{ local, session }, each a map of text to text). The page carries the visitor's storage as JSON in the client script, escaped like the base path, so the storages are full before any script of the page runs. A change is sent back withPOST <base path>/storefront-storage(both storages whole, 100 ms after the last change and at once when the page is hidden, withkeepalive, through the page's ownfetchas it was before the script wrapped it, so a theme that patchesfetchnever sees it), and the answer is 204. The route runs one visitor at a time like the cart routes, is refused with 413 pastlimits.body, keeps at most 500 names per storage and only text values, and answers 404 on a page that is not sandboxed. The visitor is the base path's token, so another visitor or another build starts empty. The storages keep the same API for theme scripts; only what a page starts with changes. - How to use it, and how to leave it out. With
sandbox: trueit is on and needs nothing from the host except thatPOST <base path>/storefront-storagereacheshandle(a host that hands onlyGETto it loses the saves, silently, and the page keeps working with in-memory storages). A customCartStoremust give back thestorageof the state it was given: a state whosestorageis not two maps of text to text is repaired like any other invalid state (reported toonReportas anerror, the cart emptied), and one that drops the field gives every page empty storages. A host that builds its own pages callsstorefrontClientScript({ basePath, storage })with the visitor's{ local, session }to start the page with them; calling it as before,storefrontClientScript({ basePath }), gives the 1.3.0 script, with in-memory storages and no saves. There is no option oncreateStorefrontthat switches the persistence off: a page that is not sandboxed has real storage and no use for it, and for a sandboxed page the storages are the visitor's, saved beside the cart.
Sample data
previewSampleDatais the sample store for previewing a theme. It isstorefrontSampleDataplus four products that the mock.shop catalogue cannot show:gift-card(gift_cardtrue, three denominations, no shipping),wholesale-tee(quantity price breaks at 10, 25 and 50 and a quantity rule of 5 to 200 in steps of 5),scented-candle(theshopify.disclosuremetafield with two disclosure metaobjects, one forUS-CA) andvideo-tote(a hosted video and a YouTube video beside its image). They sit in no collection, so/collections/all, search and their own pages list them. It is a separate export because the parity scenarios comparestorefrontSampleDatawith recordings of the parity store, and four more products would change their product lists, search results and counts;sampleStoreDataandstorefrontSampleDataare unchanged. It is frozen, like the other two. To do: nothing; passpreviewSampleDataasdatawhere you passedstorefrontSampleDatato preview those features.
Types
- New optional store data fields.
ProductData.gift_card,ProductData.complementary_products,VariantData.quantity_ruleandpopularon the entries ofMarketData.countries, andVisitorState.storage({ local: Record<string, string>, session: Record<string, string> }) on the visitor state;storefrontClientScripttakes an optionalstorageof the same shape ({ local, session }). Nothing a 1.3.0 caller passes is rejected.
Upgrading from 1.2
1.3.0 is a minor release: it adds options, routes and Shopify behaviour, and everything 1.2.0 exports is still exported, and the root entry (@debutify/liquidjs) behaves as 1.2.0 unless you opt into the new forloopParentloop option. That option is the only change to the root entry; everything else below is in @debutify/liquidjs/shopify and @debutify/liquidjs/storefront, and this is every change a 1.2.0 caller can see.
Engine
forloop.parentloopis the enclosing loop'sforloop. https://shopify.dev/docs/api/liquid/objects/forloop documents it, nil outside a nested loop; upstream LiquidJS and 1.2.0 had none, so our theme's slideshow marked no thumbnail active. As in Ruby Liquid, a loop in anincludesees the loop around theinclude, and a loop in arenderdoes not. It is on through the Shopify layer (createShopifyLiquidsetsforloopParentloop: true) and opt-in on the root entry (@debutify/liquidjs): with the option off, which is the default,forloop.parentloopreads nil, as in upstream LiquidJS and 1.2.0.ForloopDroptakes the parent loop as an optional fourth argument, and has noparentloopwithout it. To do: nothing for a Shopify theme; a root-entry caller that wants it setsforloopParentloop: true.
Filters
- New filters.
md5,preload_tag,inline_asset_content,structured_data,standard_event_dataandmoney_amountareexact, matched against the parity store's recordings of Horizon and Dawn;applicable_disclosures,line_items_for,login_button,metafield_tag,model_viewer_tagandunit_price_with_measurementareapprox, built from their shopify.dev pages. In 1.2.0 they printed nothing and were reported as stubs.inline_asset_contentreads the theme's ownassets/files (an SVG, JS or CSS file under 15 KB, an overlay's own file before its base's), never the network. powered_by_linkprints Shopify's link,<a target="_blank" rel="nofollow" href="https://www.shopify.com?utm_campaign=poweredby&utm_medium=shopify&utm_source=onlinestore">Powered by Shopify</a>, with French words on a French page, where 1.2.0 printed nothing. A caller that must not show the link removespowered_by_linkfrom its theme.defaultreturns a drop as itself. A drop that is not empty comes back as the drop, as Ruby's filter returns its input, where 1.2.0 handed on its plain value:{% assign c = setting | default: other %}keeps a drop's properties (acolorobject'srgba), so Horizon's button colours no longer printrgb(), andimage | default: other | image_urlkeeps the image's size, soimage_tagprintsheight. A drop that prints as an empty string, a drop whoseempty?is true (a settings object with no settings) and a hash with no keys take the default, as in Ruby. The root entry'sdefaultis upstream LiquidJS's.image_tagprints the parity store's order:src,alt,srcset,width,height,loading, the focal-pointstyle, then the named attributes as the template wrote them,sizesamong them (1.2.0 printedsizesandstylebeforeloading). Awidthslist stops at the URL's width.image_tagleaves out analtthat is nil.alt: nil(and an unset variable) prints noaltattribute, where 1.2.0 printedalt="";alt: ''still printsalt="". The bare media a variant filter shows as a product'sfeatured_mediahas a nilaltwhen it has no alt text of its own (an empty string, 1.2.0), so Horizon's product card, which passesalt: media.alt, prints noalton the card of a product shown through its variant, as the parity store does. To do: nothing; passalt: ''to print an empty one.image_tagtakes its size andsrcsetfrom theimage_url.{{ image | image_url: width: 3840 | image_tag: widths: … }}of an image 1024 wide printswidth="3840", the matchingheightand a candidate for every width up to 3840, as the parity store prints Horizon's collection cards; 1.2.0 cut all three to the image's own size. A width worked out from aheightalone is held to the 5760 maximum, with the ratio kept.ImageUrlDrop.paramsholds the size the template asked for.- A name written twice prints once. Every filter that writes named arguments as attributes prints a name written twice where it was first written, with its last value; 1.2.0 printed it twice.
- A focal point prints as Ruby decimals:
50.0% 50.0%, where 1.2.0 printed50% 50%, infocal_pointand inimage_tag'sstyle;1.9231% 9.7917%is unchanged. - The colour filters return a colour object.
color_darken,color_lighten,color_saturate,color_desaturate,color_modifyandcolor_mixreturn aColorDropthat prints the text 1.2.0 returned and also answers.red,.green,.blueand the other fields of a colour. A caller that checkstypeof result === 'string'readsString(result)instead. AColorDrop, acolorsetting's included, writes its text throughjson("#334fb4").sizeof the result is 0, as for acolorsetting, where 1.2.0's string gave its length (7 for#bfbfbf). firstgives the first item of a drop that iterates. Under/shopify,{{ settings.color_schemes | first }}prints the first scheme's id (Dawn'scolor-scheme-1), and a drop that iterates nothing gives nil; 1.2.0 printed nothing for any drop.firstis nowapproxinshopifyFilterRegistry(), notengine.lastand the root entry'sfirstare unchanged.find_index,findandhasread strings as Ruby does. Under/shopify,{{ sizes | find_index: '56' }}of['012', '056']is1: a string item answers the property when it contains it ('056'['56']), where 1.2.0 read56as a number and found nothing. A number reads the character at that place, as Ruby'sString#[]does ('abc'[1]is'b','056'[56]is nil). Horizon's type scale uses it, and prints--font-size--h1: clamp(3.0rem, …)and--font-size--h2: clamp(2.25rem, …)where 1.2.0 printed3.5remfor both. Thenilkeyword as the value to match means no value, sofind: 'x', niltakes the first item whosexis truthy. Hashes and drops are read as before. The three are nowapproxinshopifyFilterRegistry(), notengine; the root entry is unchanged.concatkeeps a drop as one item, and appends a drop that iterates. Under/shopify,product.featured_media | concat: other_mediastarts the list with the media object, as Ruby Liquid does, where 1.2.0 started it with the text the media prints; a drop that iterates adds its items. A card's first slide in Horizon now has its media (product-media-container--image,slide-id).concatisapproxunder/shopify; the root entry is unchanged.placeholder_svg_tagprints Shopify's outer element. The<svg>carries the attributes, their order and the viewBox Shopify prints for the same name:0 0 525.5 525.5forimage,product-Nandcollection-N,0 0 1052 400forlifestyle-N,preserveAspectRatio="xMaxYMid slice" viewBox="0 0 1300 730" fill="none"forhero-apparel-NandpreserveAspectRatio="xMidYMid slice" width="448" height="448" viewBox="0 0 448 448" fill="none"for the other*-apparel-Nnames. The drawing inside stays Debutify's own, drawn to fit that viewBox (atransformon its<path>, andfill="currentColor"where the<svg>hasfill="none"). 1.2.0 printed the0 0 525.5 525.5square for every name.
Tags
- A page's
part.titleis its number.{% if part.title == paginate.current_page %}, which Dawn 16.0.0 uses to mark the current page, now holds for the current page's part, as it does on the parity store (aria-current="page"on page 2 of a collection); 1.2.0 gave the part the text"2", which never equals the number 2, so no page was marked.{{ part.title }}still prints2, and the gap's title is still…. A theme that compares a part's title with a string (part.title == '2') no longer matches; compare with a number, or with| plus: 0as Horizon does. To do: nothing for Dawn, Horizon or a theme that compares withcurrent_page. paginategives a single page noparts. 1.2.0 gave it onecurrentpart, sopaginate.parts.sizewas 1 and a theme that prints its pagination when there are parts (Dawn's collection list:if paginate.parts.size > 0) printed a lone1; the parity store prints nothing there (private parity recordingpage-collectionsof Dawn 16.0.0).default_paginationprints nothing for a single page now. To do: nothing.- A
formgiven a nil or emptyreturn_toprints noreturn_toinput. 1.2.0 printed<input type="hidden" name="return_to" value="" />, and forlocalizationandcurrencythat empty input replaced the page's address; the parity store prints no input at all (Horizon 4.3.0's localization form passesreturn_to: backwith nobackvariable, private parity recordings of Horizon, every page). A form given noreturn_tois unchanged:localizationandcurrencystill return to the page's address. To do: nothing, unless your theme's script reads the empty input. content_fortakesclosest.*parameters.{% content_for 'blocks', closest.product: product %}and the same on'block'set whatclosestholds for the blocks it renders, their children and their settings (closest.collection,closest.article,closest.blog,closest.page, andclosest.metaobject.<definition type>); any otherclosest.parameter is an error printed inline (content_for: closest.thing is not a resource type closest holds). 1.2.0 passedclosest.productto a static block as a variable namedclosest.product, which nothing could read, and ignored the parameters of'blocks'.- A static block rendered again is numbered. The second time a section or block renders
{% content_for 'block', id: 'card' %}(in a loop) itsblock.idand wrapper id arecard-1, the third timecard-2, and every block rendered inside a numbered one ends with the same number, as the parity store prints Horizon's blog and home pages. 1.2.0 gave every repeat the same id, so a page held duplicate element ids. To do: a theme script that looked a repeated block up by its bare id finds only the first.
Objects
page_descriptionis the shop's description on/collectionsand/search. 1.2.0 gave both pages none, so a theme that prints its description meta onlyif page_description(Horizon 4.3.0, Dawn 16.0.0) printed none there; the parity store prints the shop's description on both (private parity recordingspage-collectionsandpage-searchof both themes), as it does on the home page. To do: nothing.- A menu prints as its handle.
{{ linklists['main-menu'] }}and alink_listsetting output directly printmain-menu, as the parity store printed Horizon'smenu="{{ customer_account_menu }}"(menu="customer-account-main-menu"); the directlinklists[...]print is the same object and was not recorded on its own. 1.2.0 printed[object Object].json,inspectandforare unchanged. Compare against.handleor.titleinstead of the printed text if a template depended on the old text. - The form of an existing address starts from the address. In
{% form 'customer_address', address %},form.first_name,last_name,phone,company,address1,address2,city,province,country(the country's name) andzipare the address's, HTML-escaped, where 1.2.0 left every one nil unless the visitor had posted it (so editing an address showed empty fields). A posted value still wins, and a new address form (customer.new_address, nil) has none. closestexists. The globalclosestholdsproduct,collection,article,blogandpage(nil when nothing near has one) andmetaobject.<definition type>: the resource the nearestcontent_forpassed, else the nearest resource setting of the block, its parent blocks or its section (an empty one included, which holds nil), else the template's resource. 1.2.0 had noclosest, so it read nil everywhere. To do: nothing; a theme that assigned a variable namedclosestkeeps its own.- A theme block's id has a prefix.
block.idand the wrapper id of a block fromblocks/are<prefix>__<key>(A3kq…__text_1), as the parity store prints Horizon's, where 1.2.0 printed the bare key, so CSS a theme scopes by#shopify-block-{{ block.id }}no longer reaches the block of the same key in another section. The prefix isAand 17 letters and digits, a hash of the section id and the keys from the section down to the block: the same on every render, different for each block, and shared by the repeats of a static block (…__card-1) and the blocks inside them. The entries ofsection.blockscarry the same id. A block that a section's own schema defines (Dawn's) keeps its key, as on Shopify. The report and error messages still name blocks by key. To do: a theme script or test that looked a block up by its bare key readsblock.idinstead; Shopify's own prefixes are not reproduced (shopify.dev does not document them). section.shopify_attributesis the empty string, as a block's is, and the entries ofsection.blockshave their ownblocks. 1.2.0 read nil and nothing.collections.allis titledProduitson a French page, as the parity store prints it; other languages keepProducts.- A product option's
valuesareproduct_option_valueobjects. Each value has theid,name,available,selected,variant,product_urlandswatchthat https://shopify.dev/docs/api/liquid/objects/product_option_value documents, where 1.2.0 gave plain strings, so Dawn's and Horizon's variant pickers showed every value sold out and unselected, with an emptydata-option-value-id. A value still prints, compares (option.selected_value == value), joins and escapes as its name.availablefollows the page's tree (whether a variant with the value and the values selected for the options before it can be bought),variantis the variant of the value with the other selected values, and the ids are the data's (ProductData.option_values) or a hash of the product id, the option and the value, the same on every build. A product page reads?option_values=<id>,<id>as Shopify's high-variant pickers send it and selects that variant (an unavailable one too; a combination no variant has selects none), andoptions_by_namefinds an option in any case.product.variants_countis new.value.sizeis the length of the name, as Horizon's variant picker needs (--variant-ch: 3.9emfor a six-letter value, where 1.2.0 printed0.0em). To do: a template that reads a value as a string through a filter that does not print it (value.first) readsvalue.nameinstead. - Menu links have a
handle. https://shopify.dev/docs/api/liquid/objects/link documents it; 1.2.0 left it out, so every submenu of Dawn's, Horizon's and our theme's menus got the same id (HeaderMenu-) andaria-controlspointed at the first. It isLinkData.handle, or the link's title handleized in the store's primary language, which a translated menu keeps (Shopify printsHeaderMenu-newson its English and French pages).data_sharing_opt_out_iconis nil. To do: nothing. - The shop has its policies and its password message.
shop.policies,shop.privacy_policy,refund_policy,shipping_policy,subscription_policyandterms_of_serviceare thepolicyobjects of the newShopData.policies(https://shopify.dev/docs/api/liquid/objects/shop and https://shopify.dev/docs/api/liquid/objects/policy), translated byTranslationsData.policies, where 1.2.0 had an empty list and nothing else, andshop.password_messageisShopData.password_message, which 1.2.0 never printed./policies/<handle>renders the policy in the theme's layout (renderPagewithtemplateName: 'policy',request.page_typepolicy, an empty template name), as Shopify does, and the storefront serves it;requestFromUrlroutes the address to it where 1.2.0 gave the 404 template.storefrontSampleDatahas the privacy policy the parity store has, andsampleStoreDatafour short policies, so footers that listshop.policiesand product pages that linkshop.shipping_policyprint them. To do: nothing; a store without policies prints what 1.2.0 printed. - Vendor and type collections have
current_vendorandcurrent_type, and Shopify's collections have no id. On/collections/vendors?q=and/collections/types?q=the collection'scurrent_vendororcurrent_typeis the term (https://shopify.dev/docs/api/liquid/objects/collection), so the filter forms of Dawn and our theme keep theirq; 1.2.0 had neither.collections.alland the vendor and type collections printidas nil, as the parity store does (data-collection-id=""), where 1.2.0 printed0. A collection's own image reads the collection's title as itsaltwhen it has none, as Shopify prints it (alt="Men"). To do: code that tellscollections.allapart bycollection.id == 0reads itshandleinstead. - Metafields are
metafieldobjects.resource.metafields.<namespace>.<key>on a product, variant, collection, page, blog, article, shop and customer has thevalue,typeandlist?of https://shopify.dev/docs/api/liquid/objects/metafield, where 1.2.0 gave the stored value, whose.valuewas nil (the review blocks of the three themes readmetafields.reviews.rating.value). A metafield prints as it is stored, so{{ product.metafields.custom.material }}still printsCotton, and the storefront filters read it the same way. AMetafieldsentry is either a plain value, whose type is read from it (a string, a number, a boolean, a list of strings, or JSON), or{ type, value }(MetafieldData) with a type the page lists, whosevalueis then typed: arating(printing its rating), acolor, a measurement (printing value and unit), arich_text_field(printing its HTML),money, a product, collection, page or variant reference by handle or id (the referenced object, andcounton a list of them), afile_referenceimage andjson. To do: a template that compared a metafield with==or passed it to a filter as its value still works; one that read.valueof a value you stored as an object{ type, value }of your own now reads the typed value. - Variants and cart lines have their unit prices, volume prices and pickup stock. A variant has
barcode,unit_priceandunit_price_measurement(fromVariantData.unit_price_measurement),quantity_price_breaks(VariantData.quantity_price_breaks,
