npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@debutify/liquidjs

v1.6.0

Published

Debutify's fork of LiquidJS with Shopify Liquid semantics, for rendering Shopify themes outside Shopify.

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 by assign) are LiquidFloat objects: read .value, or use Number(x).
  • A rawTags list replaces the default ['raw'] instead of adding to it. Without raw in it, the body of {% raw %} is parsed as Liquid, so rawTags: ['comment'] makes {% raw %}{{ x {% endraw %} a tokenization error.
  • Setting rawTags explicitly, 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 in errors and not written.
  • With inlineErrors, an undefined variable is recorded and not written, but it is an error only under strictVariables; 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). Under strictFilters a misspelt filter in an ordinary tag is a parse error and still throws, as in upstream LiquidJS, and without strictFilters it 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 as Liquid syntax error (<file> line <n>): <message>. A syntax error in the template you pass to parse still throws, and so does a limit error (memoryLimit, renderLimit, parseLimit). A limit hit while rendering arrives wrapped in a RenderError, so test what you catch with isLimitError(err), which looks through originalError; LimitError.is(err) is false for the wrapper.
  • With topLevelSize: false, {{ size }} prints nothing, {% if size %} is false and {{ size | default: 'M' }} prints M when nothing defines size. Under strictVariables an undefined size throws 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' }} is Variable '{{ '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 is Variable '{{' was not properly terminated…, an unclosed tag Tag '{%' was not properly terminated with regexp: /\%\}/, and a string that never closes Unexpected character ', where 1.5.0 said output … not closed or accepted the string. The root engine keeps upstream LiquidJS's reading unless rubyTokenizer: true is set. To do: a theme whose output holds a } in a string must move it out of the output.
  • Shopify.loadFeatures loads Shopify's feature scripts. A registered feature (model-viewer 0.6 to 1.12, model-viewer-ui 1.0, shopify-xr 1.0, video-ui 1.0, 1.1 and 2.0, consent-tracking-api 0.1 and 0.2) loads https://cdn.shopify.com/shopifycloud/<folder>/v<version>/<file>[.<locale>].js as a module script, as Shopify's load_feature-<hash>.js does (the engine reads the same mapping, so a new hash does not stale it), and onLoad and the callback get no error once it ran; 1.5.0 answered load error: … is not available in the preview and 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, and Shopify.loadFeatures answers an unregistered feature at once, as the loader does, not after the current task. The preview host must let those scripts load from cdn.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: *) from https://cdn.shopify.com/3d/models/o/<id>/box.glb, but a preview is served from another address, so the model never loaded. With sandbox: true the storefront prints the data's address, https://cdn.shopify.com/3d/models/o/<id>/box.glb (and https://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 optional storeFiles: 'cdn' on RequestInput and StoreView does 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

  • assign holds the empty string for blank and empty. Ruby Liquid reads the literals blank and empty as '' outside a condition (Expression::LITERALS), so after {% assign x = blank %} (or empty), {% if x == blank %} and {% if x == empty %} run their branch and {{ x }} prints nothing, while the literal {% if blank == blank %} stays false (liquid-spec Comparing blank and blank resolves to false). The parity store's product page relies on it: the theme's review snippet starts from blank, tests rating == blank and prints aggregateRating only when a rating replaced it (scenario page-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.sku and variants.barcode for a product; title, body and author for a page; those and tag for an article), a quoted phrase, -term and NOT term, OR, AND, parentheses. Horizon's recently viewed products ask for id:123 OR id:456; 1.4.0 split that into words and found nothing. The meaning of each is what the parity store answers (fixtures probe-search-q-*, 34 recorded queries): a field narrows, OR joins, AND and 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 lowercase and or or among other words is not searched.
  • search.terms is HTML-escaped, as the parity store prints it (&quot;gray leather&quot;), 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 leave resources[...] out. A product's id finds no page or article (fixture probe-search-w-id-all), and resources[type] and resources[limit], which only the predictive search reads, no longer appear in url_to_add and url_to_remove (fixture probe-search-w-resources-type).

Objects

  • link.object is 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, moderate or yes, as the Admin API has it) sets blog.comments_enabled? and blog.moderated?, and ArticleData.comments the published comments. article.comments, comments_count, comments_enabled?, moderated? and comment_post_url follow, and a comment object has author, content, created_at, email, id, status, updated_at and url. 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.parentKey nests a line under another; line_item.parent_relationship.parent is the parent line item (Dawn and Horizon mark and label the nested line from it), and the Ajax answers print parent_relationship and instructions for 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 by all_products, by its id and by its variants in the cart, and it is left out of every list of products, collections.all with its products_count, types and vendors among them. VariantData.taxable (boolean, true when omitted) sets variant.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 starts content_for_header with <meta name="robots" content="noindex,nofollow"> (fixture page-product-video), and /products/<handle>.js leaves out the featured_media of a variant that has no image where 1.4.0 printed null (fixture ajax-product-js-media). To do: nothing.
  • A 3D model is a media. ModelMediaData (media_type: 'model', id, alt, preview_image, sources) in ProductData.media, each source a ModelSourceData (url, mime_type, format), gives product.media a model with sources and no aspect_ratio (https://shopify.dev/docs/api/liquid/objects/model, https://shopify.dev/docs/api/liquid/objects/model_source). 1.4.0's MediaData held images and videos only, so a theme met a model only in data a caller supplied, and media_tag printed nothing for it. A source on Shopify's CDN (https://cdn.shopify.com/3d/models/…) reads //<shop domain>/cdn/shop/3d/models/…?v=0 in Liquid and stays as it is in the Ajax answer, where a model has only its sources (fixture ajax-product-js-media, scenario page-product-model). To do: nothing.
  • A hosted video and an external video read as the parity store prints them. A video's sources on Shopify's CDN (https://cdn.shopify.com/videos/…) read //<shop domain>/cdn/shop/videos/…?v=0 in Liquid with the HLS playlist (m3u8) listed first, and stay as they are with the playlist last in the Ajax answer (fixture ajax-product-js-media, scenario page-product-video). aspect_ratio is rounded to three places (1.778), while preview_image.aspect_ratio stays whole (--preview-ratio: 1.7777777777777777), and the preview_image has the media's alt (<img alt="The tote in a short clip">). 1.4.0 kept the data's addresses, order and ratio, and gave the preview its own alt.
  • Unit prices and ratings reach the product answers. A variant with a unit_price_measurement prints unit_price and the measurement in /products/<handle>.js and in product | json, with quantity_value as text with a decimal ("250.0"), after quantity_rule in the first and after requires_selling_plan in the second; 1.4.0 left both out. A rating metafield's rating, scale_min and scale_max print as text in json ({{ rating.value.rating | json }} is "4.5", "bestRating" is "5.0") and as numbers everywhere else (scenario page-product-unit-price, fixture probe-product-extras-unit-price). To do: nothing.
  • routes.account_login_url is the customer login on the request's own origin. https://<host>/customer_authentication/redirect?locale=fr&region_country=CA, under the base path and with no locale prefix, locale the page's language and region_country the shopper's country (fixtures probe-page-home, probe-page-fr-ca and probe-page-en-eu); 1.4.0 printed <prefix>/account/login. The storefront answers the address with the notice page.
  • country_option_tags and all_country_option_tags print 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's to_json writes them (&, < and > as &, < and >) and then escaped, apostrophe included (O&#39;Higgins), and the label has only < and > escaped (Antigua & Barbuda). CountryOptionData.label is the text the option shows when it is not the name (Åland Islands for the name Aland Islands). 1.4.0 escaped the name and the label alike and had no label (scenario ajax-cart-lines, the cart's shipping calculator).
  • collections.all lists the unlisted products' tags, types and vendors. On the parity store, all_tags, all_types and all_vendors of collections.all count the unlisted products, while its products, products_count, tags and filter values leave them out, and no other collection counts them (fixtures probe-collection-*). The vendors and types collections keep all_types in the store's order. 1.4.0 left unlisted products out of every list.

Filters

  • payment_button prints 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's access-token and shop-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, scenarios page-product*). The token and id are a stable pair made from the store's domain, not Shopify's (accepted part accelerated-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_buttons stays false and content_for_additional_checkout_buttons empty, as the parity store's cart pages report them (recordings ajax-cart-lines, ajax-cart-lines-dawn and ajax-cart-lines-horizon). To do: see the content_for_header bullet under Pages for the files the markup loads.
  • image_url crops from the centre when it has a width and a height. image_url: width: 550, height: 550, and 400 by 225, 300 by 100 and 400 by 400, print crop=center, on an image and on the preview of a video or a model, and so does Horizon's media | image_url: width: 550, height: 550; a width or a height alone prints no crop, and a crop or pad_color the template names is kept (https://shopify.dev/docs/api/liquid/filters/image_url, scenario probe-product-video-media). 1.4.0 added no crop. To do: nothing.
  • video_tag writes a video as the parity store does. The template's attributes come in the order it wrote them; a preload it names replaces the default preload="metadata" in its place; autoplay without muted adds muted after the preload; 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 its src (https://shopify.dev/docs/api/liquid/filters/video_tag, scenarios probe-product-video-media and page-product-video). 1.4.0 always wrote preload="metadata" first, skipped a playlist only under loop, and left the fallback without an alt.
  • media_tag prints an image and a 3D model. An image is an <img> with the template's attributes in the order written, then alt and the src of the legacy image_size file (small by default, _400x for 400x), with no srcset, width or height (scenario probe-product-video-media); 1.4.0 printed image_tag at the image's own width. A model prints its <model-viewer> (https://shopify.dev/docs/api/liquid/filters/media_tag, scenario page-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, in video_tag's poster, model_viewer_tag's poster and an image's media_tag (…thumbnail.0000000000.jpg, where small prints …_small.jpg).
  • model_viewer_tag puts 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; alt keeps the media's alt text (alt: 'A cube' prints alt="The cube in 3D"); a boolean prints as text (toggleable="true", camera-controls="false") (https://shopify.dev/docs/api/liquid/filters/model_viewer_tag, scenarios probe-product-model-media and page-product-model). 1.4.0 kept each default where it stood and replaced its value.
  • external_video_url writes a boolean as 1 or 0. autoplay: true prints autoplay=1, where 1.4.0 printed autoplay=true, as the parity store prints it for a hosted or external video.
  • t does 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.title is now such a value, and escape leaves it alone as well, so the cart's aria-label="Remove Men&#39;s T-shirt" is what the parity store prints in all three themes, where 1.4.0 printed Men&amp;#39;s. /cart.js and cart | json still print the title as Men&#39;s T-shirt - Small / Green, and the title in the add, change and update answers is plain text (fixture ajax-discount-codes). A key that ends in _html is unchanged.
  • standard_event_data prints 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"} with applicable first (recordings ajax-checkout-redirects of Horizon and ajax-cart-lines-dawn). 1.4.0 printed null for every cart without lines and wrote code first.
  • json prints a media as the Ajax answer does. product.media | json and one media's | json print a video, an external video or a 3D model with its alt, id, position, preview_image and media_type and its own fields (a video's aspect_ratio, duration and sources, an external video's aspect_ratio, external_id and host, a model's sources; each source with format, height, mime_type, url and width, a model's without the size), a video's playlist last and the preview_image.aspect_ratio rounded to three places (1.778, 1.333; {{ media.preview_image.aspect_ratio }} prints the whole ratio). media.preview_image | json is the picture's address, as product.images | json lists one (fixtures ajax-product-js-media and probe-product-video-media, scenarios page-product-model and probe-product-model-media). 1.4.0 printed the built media object as it was.
  • json refuses 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's sources (each source), for product.metafields and the other metafields objects (also when empty), for a rating metafield (and each rating of a list.rating) and for a variant's unit_price_measurement (scenarios probe-product-video-media, probe-product-model-media, probe-product-extras-unit-price, probe-product-extras-gift-card and probe-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 | json still writes a variant's measurement and a media's files itself. To do: a theme that prints one of these through json now prints the error object, as it does on Shopify.
  • inline_asset_content of a missing asset prints <!-- inline_asset_content: Asset not found. -->, as the parity store does on Dawn's product page (recording page-product-model, https://shopify.dev/docs/api/liquid/filters/inline_asset_content). 1.4.0 printed nothing.

Pages

  • content_for_header links 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 is all, its oEmbed; a blog and its articles link the blog's Atom feed (recordings page-product, page-product-variant, page-product-requires-plan, page-product-en-eu, page-collection, page-collection-filtered, page-blog and page-article). Every page has Shopify.PaymentButton with its init, the buyer consent helpers and an observer that loads the wallets module when a shopify-accelerated-checkout or shopify-accelerated-checkout-cart element appears; a page that printed a payment_button also has the cleanup helpers, the <script type="module"> of the module with its nomodule twin, the accelerated-checkout.css link 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 step atom-feeds). To do: a caller of renderPage serves the two files at the address ShopifyServices.shopifyCloudUrl gives (/cdn/shopifycloud/<path> by default) or lets them 404; createStorefront serves them.
  • window.Shopify carries what Shopify's own header sets. Shopify.theme (name, id, schema_name, schema_version, theme_store_id null, role main, handle the text null, style), Shopify.cdnHost (<permanent domain>/cdn), Shopify.SignInWithShop.eligible false, Shopify.MCP (disabled, no tools, an empty endpoint) and Shopify.featureAssets['shop-js'] (an empty map), the last three written, as Shopify writes them, in scripts of their own (window.Shopify = window.Shopify || {}; then window.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 from theme_info in config/settings_schema.json, and its id is a number made from them. Shopify.designMode is no longer printed, since Shopify defines it in the theme editor only. Shopify.modules, Shopify.loadFeatures and Shopify.autoloadFeatures exist and answer as Shopify's feature loader does: a feature it does not register fails with does not exist and 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 a TypeError. A page that holds a <shopify-account> element defines it and <shopify-store>: a link to routes.account_login_url in the shadow structure of Shopify's legacy accounts, with showModal(), close() and the open event Horizon's header listens for (Debutify's stand-in; Shopify's account.js needs its customer account services). To do: a theme that reads Shopify.designMode reads undefined.
  • renderSection takes exact. RenderSectionOptions.exact (false when omitted) answers only to the ids the Section Rendering API takes (the id renderPage reports, a static section's file name or a group's id) and not to a bare key of the JSON template's sections. The storefront sets it for ?sections= and ?section_id=: the parity store answers ?sections=cart-items with null for a template's cart-items section and renders it for template--<id>__cart-items, where 1.4.0 rendered it for the bare key. A caller of renderSection that leaves it out behaves as under 1.4.0.

Storefront

  • POST <article url>/comments takes a comment. The new_comment form posts there. A blog that takes none, or an article it lacks, answers the 404 page; an incomplete comment renders the article again with form.errors; a valid one is kept with the visitor (VisitorState.comments) and redirects to the article with ?comment=<id>#comment_form, where form.posted_successfully? is true. On a blog that publishes comments it joins article.comments; on one that moderates them it does not, and the page's comment has the status unapproved, which the themes read to say it awaits approval. See Routes.
  • add.js takes parent_id and parent_line_key, and holds the cart to the quantity rules. parent_id names a variant of the same request, parent_line_key a line the cart holds; a nested line is listed right after its parent and goes with it. add.js and change.js answer 422 for a quantity under the minimum, over the maximum or off the increment of the variant (counting every line of it), and update.js does for a variant not yet in the cart. See The cart.
  • /checkout and /account answer a 302, as the parity store does. /checkout (GET or POST) 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's checkout button goes to that address as well. /account and every address under it redirect to /customer_authentication/redirect?locale=<language>&region_country=<country>. The notice page now answers /checkouts/… and /customer_authentication/… with status 200, and the notice option still receives 'checkout' or 'account'. 1.4.0 answered /checkout, /account and everything under them with the notice page, and sent the checkout button to /checkout. The cart token, _r and the permalink's cart_link_id are made by the storefront. The empty-cart rule is inferred from one recorded session of seven requests (scenarios ajax-checkout-redirects and ajax-cart-form-add; accepted rules customer-accounts and checkout-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 with cart_link_id, or with ?storefront=true to /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, scenario ajax-checkout-redirects, accepted step cart-link-gone-page). 1.4.0 answered the 404 page.
  • /products/<handle>.oembed and /collections/<handle>.oembed answer an oEmbed. A product with one offer for each variant, or a collection with its products oldest first, written as Rails writes JSON, as application/json+oembed; oembedJson(data, 'product' | 'collection', handle, view?) from /shopify is its text. A handle the store lacks answers 404 with an empty body. 1.4.0 answered the 404 page for both (scenario ajax-endpoint-links, compared in full).
  • /cdn/shopifycloud/portable-wallets/latest/… serves the module that fills <shopify-accelerated-checkout>. portable-wallets.<language>.js and accelerated-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 /contact redirects to the page with contact_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 rule contact-bot-challenge).
  • A recommendations section sees the product it was asked for. A section rendered by /recommendations/products has product set to the product of product_id, as on that product's own page, so Horizon's data-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 left product empty 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-Policy in sandbox mode is sandbox allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox, where 1.4.0 sent sandbox 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 in Content-Security-Policy on every answer under the base path and in the sandbox attribute of the iframe (see Sandboxed pages); allow-same-origin stays off. To do: change both places; a host that keeps the two tokens keeps new tabs blocked.
  • A parent_id that names a cart line but no item of the request answers Shopify's error. add.js answers 422 Cannot reference existing parent lines by variant_id. (fixture ajax-cart-nested), where a parent_id that matches neither the request nor the cart keeps its own error.

Sample data

  • storefrontSampleData holds 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) and pickup-mug (stock at one location, a size sold past zero stock), each unlisted: true, so every list, search and count is what the parity store prints, and each page opens at its address (scenarios page-product-video, page-product-model, page-product-unit-price and probe-product-extras-pickup); their media files are the parity store's own. Its country_option_tags is the 237 countries Shopify lists, with their subregions, where 1.4.0 held four (scenario ajax-cart-lines). sampleStoreData is unchanged. To do: nothing; a caller that counted the products of storefrontSampleData by listing data.products now counts five more.
  • previewSampleData has two products of its own and lists the unlisted ones. wholesale-tee and scented-candle are its own; the five unlisted products above come with unlisted false, so /collections/all and search list them. 1.4.0 added four products of its own, among them a gift-card and a video-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, and arrangeNested and quantityRuleOf from /shopify.
  • More optional fields and exports. ProductData.unlisted, VariantData.taxable, CountryOptionData.label, ShopifyServices.shopifyCloudUrl (optional like every key of the services option, which is a Partial), RenderSectionOptions.exact, ModelMediaData and ModelSourceData, and oembedJson from /shopify. MediaData is now ImageMediaData | VideoMediaData | ExternalVideoMediaData | ModelMediaData, so a switch over media_type that checks every case needs a 'model' case. In the state that content_for_header reads, which is not exported, the request gains noindex (true on the page of an unlisted product) and endpoints (buy, atom and oembed), and the state gains checkoutButton (a payment_button printed) and theme ({ name, version } from theme_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: continue starts where the previous loop ended, also for the first loop. https://shopify.dev/docs/api/liquid/tags/for documents offset: continue as starting the loop where the previous for over the same collection stopped; with no previous loop that is the start, as in Ruby Liquid, where the offset is offsets[name].to_i. 1.3.0 (and upstream LiquidJS) read an empty object as the offset when no loop had run before, which is NaN: the loop started at 0 and stored a string, so every later offset: continue over 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? and line_item.gift_card follow 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 has gift_card true. A gift card is left out of recommendations, as before. To do: set gift_card: true on the product and requires_shipping: false on its variants.
  • A variant has quantity_price_breaks_configured? and takes its quantity_rule from the data. https://shopify.dev/docs/api/liquid/objects/variant documents quantity_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 when VariantData.quantity_price_breaks holds 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 (min 1, no max, increment 1), so volume pricing and minimum or step quantities could not be previewed. VariantData.quantity_rule ({ min?, max?, increment? }; min and increment default to 1 and max to 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 on localization.country and on each of localization.available_countries, from the new optional popular of the entries of MarketData.countries ({ iso_code, name, popular? }), false when omitted.
  • recommendations with intent=complementary lists 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 (fixture probe-recommendations-complementary). This also reaches recommendationsJson and /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 a section_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 with section_id=template--product__<key>. That request is read as the index template, so 1.3.0 looked the section up there and renderSection and /recommendations/products answered 404 (the section was not found). renderSection now 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 -, so product.probe is product-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 localStorage and sessionStorage across 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), and window.name would 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 with POST <base path>/storefront-storage (both storages whole, 100 ms after the last change and at once when the page is hidden, with keepalive, through the page's own fetch as it was before the script wrapped it, so a theme that patches fetch never sees it), and the answer is 204. The route runs one visitor at a time like the cart routes, is refused with 413 past limits.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: true it is on and needs nothing from the host except that POST <base path>/storefront-storage reaches handle (a host that hands only GET to it loses the saves, silently, and the page keeps working with in-memory storages). A custom CartStore must give back the storage of the state it was given: a state whose storage is not two maps of text to text is repaired like any other invalid state (reported to onReport as an error, the cart emptied), and one that drops the field gives every page empty storages. A host that builds its own pages calls storefrontClientScript({ 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 on createStorefront that 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

  • previewSampleData is the sample store for previewing a theme. It is storefrontSampleData plus four products that the mock.shop catalogue cannot show: gift-card (gift_card true, 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 (the shopify.disclosure metafield with two disclosure metaobjects, one for US-CA) and video-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 compare storefrontSampleData with recordings of the parity store, and four more products would change their product lists, search results and counts; sampleStoreData and storefrontSampleData are unchanged. It is frozen, like the other two. To do: nothing; pass previewSampleData as data where you passed storefrontSampleData to preview those features.

Types

  • New optional store data fields. ProductData.gift_card, ProductData.complementary_products, VariantData.quantity_rule and popular on the entries of MarketData.countries, and VisitorState.storage ({ local: Record<string, string>, session: Record<string, string> }) on the visitor state; storefrontClientScript takes an optional storage of 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.parentloop is the enclosing loop's forloop. 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 an include sees the loop around the include, and a loop in a render does not. It is on through the Shopify layer (createShopifyLiquid sets forloopParentloop: true) and opt-in on the root entry (@debutify/liquidjs): with the option off, which is the default, forloop.parentloop reads nil, as in upstream LiquidJS and 1.2.0. ForloopDrop takes the parent loop as an optional fourth argument, and has no parentloop without it. To do: nothing for a Shopify theme; a root-entry caller that wants it sets forloopParentloop: true.

Filters

  • New filters. md5, preload_tag, inline_asset_content, structured_data, standard_event_data and money_amount are exact, matched against the parity store's recordings of Horizon and Dawn; applicable_disclosures, line_items_for, login_button, metafield_tag, model_viewer_tag and unit_price_with_measurement are approx, built from their shopify.dev pages. In 1.2.0 they printed nothing and were reported as stubs. inline_asset_content reads the theme's own assets/ files (an SVG, JS or CSS file under 15 KB, an overlay's own file before its base's), never the network.
  • powered_by_link prints Shopify's link, <a target="_blank" rel="nofollow" href="https://www.shopify.com?utm_campaign=poweredby&amp;utm_medium=shopify&amp;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 removes powered_by_link from its theme.
  • default returns 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 (a color object's rgba), so Horizon's button colours no longer print rgb(), and image | default: other | image_url keeps the image's size, so image_tag prints height. A drop that prints as an empty string, a drop whose empty? is true (a settings object with no settings) and a hash with no keys take the default, as in Ruby. The root entry's default is upstream LiquidJS's.
  • image_tag prints the parity store's order: src, alt, srcset, width, height, loading, the focal-point style, then the named attributes as the template wrote them, sizes among them (1.2.0 printed sizes and style before loading). A widths list stops at the URL's width.
  • image_tag leaves out an alt that is nil. alt: nil (and an unset variable) prints no alt attribute, where 1.2.0 printed alt=""; alt: '' still prints alt="". The bare media a variant filter shows as a product's featured_media has a nil alt when it has no alt text of its own (an empty string, 1.2.0), so Horizon's product card, which passes alt: media.alt, prints no alt on the card of a product shown through its variant, as the parity store does. To do: nothing; pass alt: '' to print an empty one.
  • image_tag takes its size and srcset from the image_url. {{ image | image_url: width: 3840 | image_tag: widths: … }} of an image 1024 wide prints width="3840", the matching height and 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 a height alone is held to the 5760 maximum, with the ratio kept. ImageUrlDrop.params holds 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 printed 50% 50%, in focal_point and in image_tag's style; 1.9231% 9.7917% is unchanged.
  • The colour filters return a colour object. color_darken, color_lighten, color_saturate, color_desaturate, color_modify and color_mix return a ColorDrop that prints the text 1.2.0 returned and also answers .red, .green, .blue and the other fields of a colour. A caller that checks typeof result === 'string' reads String(result) instead. A ColorDrop, a color setting's included, writes its text through json ("#334fb4"). size of the result is 0, as for a color setting, where 1.2.0's string gave its length (7 for #bfbfbf).
  • first gives the first item of a drop that iterates. Under /shopify, {{ settings.color_schemes | first }} prints the first scheme's id (Dawn's color-scheme-1), and a drop that iterates nothing gives nil; 1.2.0 printed nothing for any drop. first is now approx in shopifyFilterRegistry(), not engine. last and the root entry's first are unchanged.
  • find_index, find and has read strings as Ruby does. Under /shopify, {{ sizes | find_index: '56' }} of ['012', '056'] is 1: a string item answers the property when it contains it ('056'['56']), where 1.2.0 read 56 as a number and found nothing. A number reads the character at that place, as Ruby's String#[] 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 printed 3.5rem for both. The nil keyword as the value to match means no value, so find: 'x', nil takes the first item whose x is truthy. Hashes and drops are read as before. The three are now approx in shopifyFilterRegistry(), not engine; the root entry is unchanged.
  • concat keeps a drop as one item, and appends a drop that iterates. Under /shopify, product.featured_media | concat: other_media starts 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). concat is approx under /shopify; the root entry is unchanged.
  • placeholder_svg_tag prints 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.5 for image, product-N and collection-N, 0 0 1052 400 for lifestyle-N, preserveAspectRatio="xMaxYMid slice" viewBox="0 0 1300 730" fill="none" for hero-apparel-N and preserveAspectRatio="xMidYMid slice" width="448" height="448" viewBox="0 0 448 448" fill="none" for the other *-apparel-N names. The drawing inside stays Debutify's own, drawn to fit that viewBox (a transform on its <path>, and fill="currentColor" where the <svg> has fill="none"). 1.2.0 printed the 0 0 525.5 525.5 square for every name.

Tags

  • A page's part.title is 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 prints 2, and the gap's title is still &hellip;. A theme that compares a part's title with a string (part.title == '2') no longer matches; compare with a number, or with | plus: 0 as Horizon does. To do: nothing for Dawn, Horizon or a theme that compares with current_page.
  • paginate gives a single page no parts. 1.2.0 gave it one current part, so paginate.parts.size was 1 and a theme that prints its pagination when there are parts (Dawn's collection list: if paginate.parts.size > 0) printed a lone 1; the parity store prints nothing there (private parity recording page-collections of Dawn 16.0.0). default_pagination prints nothing for a single page now. To do: nothing.
  • A form given a nil or empty return_to prints no return_to input. 1.2.0 printed <input type="hidden" name="return_to" value="" />, and for localization and currency that empty input replaced the page's address; the parity store prints no input at all (Horizon 4.3.0's localization form passes return_to: back with no back variable, private parity recordings of Horizon, every page). A form given no return_to is unchanged: localization and currency still return to the page's address. To do: nothing, unless your theme's script reads the empty input.
  • content_for takes closest.* parameters. {% content_for 'blocks', closest.product: product %} and the same on 'block' set what closest holds for the blocks it renders, their children and their settings (closest.collection, closest.article, closest.blog, closest.page, and closest.metaobject.<definition type>); any other closest. parameter is an error printed inline (content_for: closest.thing is not a resource type closest holds). 1.2.0 passed closest.product to a static block as a variable named closest.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) its block.id and wrapper id are card-1, the third time card-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_description is the shop's description on /collections and /search. 1.2.0 gave both pages none, so a theme that prints its description meta only if 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 recordings page-collections and page-search of both themes), as it does on the home page. To do: nothing.
  • A menu prints as its handle. {{ linklists['main-menu'] }} and a link_list setting output directly print main-menu, as the parity store printed Horizon's menu="{{ customer_account_menu }}" (menu="customer-account-main-menu"); the direct linklists[...] print is the same object and was not recorded on its own. 1.2.0 printed [object Object]. json, inspect and for are unchanged. Compare against .handle or .title instead 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) and zip are 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.
  • closest exists. The global closest holds product, collection, article, blog and page (nil when nothing near has one) and metaobject.<definition type>: the resource the nearest content_for passed, 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 no closest, so it read nil everywhere. To do: nothing; a theme that assigned a variable named closest keeps its own.
  • A theme block's id has a prefix. block.id and the wrapper id of a block from blocks/ 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 is A and 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 of section.blocks carry 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 reads block.id instead; Shopify's own prefixes are not reproduced (shopify.dev does not document them).
  • section.shopify_attributes is the empty string, as a block's is, and the entries of section.blocks have their own blocks. 1.2.0 read nil and nothing.
  • collections.all is titled Produits on a French page, as the parity store prints it; other languages keep Products.
  • A product option's values are product_option_value objects. Each value has the id, name, available, selected, variant, product_url and swatch that 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 empty data-option-value-id. A value still prints, compares (option.selected_value == value), joins and escapes as its name. available follows the page's tree (whether a variant with the value and the values selected for the options before it can be bought), variant is 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), and options_by_name finds an option in any case. product.variants_count is new. value.size is the length of the name, as Horizon's variant picker needs (--variant-ch: 3.9em for a six-letter value, where 1.2.0 printed 0.0em). To do: a template that reads a value as a string through a filter that does not print it (value.first) reads value.name instead.
  • 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-) and aria-controls pointed at the first. It is LinkData.handle, or the link's title handleized in the store's primary language, which a translated menu keeps (Shopify prints HeaderMenu-news on its English and French pages). data_sharing_opt_out_icon is nil. To do: nothing.
  • The shop has its policies and its password message. shop.policies, shop.privacy_policy, refund_policy, shipping_policy, subscription_policy and terms_of_service are the policy objects of the new ShopData.policies (https://shopify.dev/docs/api/liquid/objects/shop and https://shopify.dev/docs/api/liquid/objects/policy), translated by TranslationsData.policies, where 1.2.0 had an empty list and nothing else, and shop.password_message is ShopData.password_message, which 1.2.0 never printed. /policies/<handle> renders the policy in the theme's layout (renderPage with templateName: 'policy', request.page_type policy, an empty template name), as Shopify does, and the storefront serves it; requestFromUrl routes the address to it where 1.2.0 gave the 404 template. storefrontSampleData has the privacy policy the parity store has, and sampleStoreData four short policies, so footers that list shop.policies and product pages that link shop.shipping_policy print them. To do: nothing; a store without policies prints what 1.2.0 printed.
  • Vendor and type collections have current_vendor and current_type, and Shopify's collections have no id. On /collections/vendors?q= and /collections/types?q= the collection's current_vendor or current_type is the term (https://shopify.dev/docs/api/liquid/objects/collection), so the filter forms of Dawn and our theme keep their q; 1.2.0 had neither. collections.all and the vendor and type collections print id as nil, as the parity store does (data-collection-id=""), where 1.2.0 printed 0. A collection's own image reads the collection's title as its alt when it has none, as Shopify prints it (alt="Men"). To do: code that tells collections.all apart by collection.id == 0 reads its handle instead.
  • Metafields are metafield objects. resource.metafields.<namespace>.<key> on a product, variant, collection, page, blog, article, shop and customer has the value, type and list? of https://shopify.dev/docs/api/liquid/objects/metafield, where 1.2.0 gave the stored value, whose .value was nil (the review blocks of the three themes read metafields.reviews.rating.value). A metafield prints as it is stored, so {{ product.metafields.custom.material }} still prints Cotton, and the storefront filters read it the same way. A Metafields entry 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, whose value is then typed: a rating (printing its rating), a color, a measurement (printing value and unit), a rich_text_field (printing its HTML), money, a product, collection, page or variant reference by handle or id (the referenced object, and count on a list of them), a file_reference image and json. To do: a template that compared a metafield with == or passed it to a filter as its value still works; one that read .value of 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_price and unit_price_measurement (from VariantData.unit_price_measurement), quantity_price_breaks (VariantData.quantity_price_breaks,