local-storefront 0.2.0 → 0.3.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,502 @@
1
+ # Compatibility with Shopify
2
+
3
+ The goal is HTML identical to what Shopify renders for the same theme and the same data. This
4
+ page says how that is checked, what is covered, and where the local server knowingly differs.
5
+
6
+ ## How fidelity is checked
7
+
8
+ - **Liquid itself** is a port of Shopify's reference implementation (the `liquid` Ruby gem,
9
+ version 5.14), including its lax parsing mode, whitespace control, number and string
10
+ coercions, and error messages. 616 templates covering every tag and filter are rendered by
11
+ both implementations and must produce the same output (`mise run oracle:golden` regenerates
12
+ the expectations from the gem).
13
+ - **Whitespace is trimmed the way storefronts trim it**, which is not quite what the language
14
+ says. `{{-` and `{%-` remove the whitespace before them. When the text before one is nothing
15
+ but whitespace, a storefront keeps its first character:
16
+
17
+ ```liquid
18
+ {{ 'a' }}
19
+ {{- 'b' }}
20
+ ```
21
+
22
+ prints `a` and `b` on two lines on Shopify, and `ab` in the language. The gem has this
23
+ behaviour as a parse option (`bug_compatible_whitespace_trimming`), and themes are rendered
24
+ with it. It is what gives a `robots.txt.liquid` its line breaks: the `robots.txt` of a
25
+ store, rendered from its own template, is reproduced byte for byte.
26
+ - **Shopify's test suite of the language**,
27
+ [liquid-spec](https://github.com/Shopify/liquid-spec), is replayed against the engine
28
+ (`mise run liquid-spec:fetch` downloads it). 4,630 of its 7,369 language specs apply here, and
29
+ 4,608 of them pass. The others expect a strict parser to reject a template that Shopify
30
+ accepts in a theme (1,923), or need what the engine has no equivalent for: Ruby objects, byte
31
+ strings, render limits (816). The 22 that differ are [listed
32
+ below](#differences-with-liquid-spec). What the strict parser rejects is checked by
33
+ `npx local-storefront check`: see [Shopify's strict Liquid
34
+ parser](#shopifys-strict-liquid-parser).
35
+ - **Shopify's objects, filters and tags** are checked against the examples of
36
+ [shopify.dev](https://shopify.dev/docs/api/liquid): each example's code and data are replayed
37
+ and compared with the documented output. 196 of 313 examples match. The others depend on the
38
+ content of Shopify's own demo store (products, images and ids that are not in the examples'
39
+ data), not on behaviour known to differ.
40
+ - **Real themes.** Shopify's Horizon and Dawn themes render every page type without a Liquid
41
+ error, and their JavaScript (variant pickers, cart drawer, predictive search, filters, section
42
+ rendering, pickup availability) runs against the local endpoints unchanged.
43
+ - **Functions and checkout extensions.** A JavaScript Function is built in the steps of Shopify's
44
+ CLI (Javy writes the same bytes for the same JavaScript), and its module costs the instructions
45
+ the CLI's would, within a few hundred: `crates/app/src/checkout/javascript/tests.rs` compares them
46
+ with those of Shopify's CLI 5.0.0. A Rust module is linked by Shopify's own trampoline, to the
47
+ byte. A Function's input is built from its input query, and its
48
+ output checked, against the schema of its API version. The checkout UI extensions lsf bundles
49
+ are drawn next to the CLI's bundles of the same sources, acted on and compared
50
+ (`crates/app/assets/tests/built-extensions.test.mjs`). What the local checkout leaves out
51
+ or decides on its own: [what to test on a real store](checkout.md#what-to-test-on-a-real-store).
52
+
53
+ `npx local-storefront check` reports what a theme uses that is not implemented: Liquid that does
54
+ not parse, and filters that do not exist here.
55
+
56
+ ## Liquid tags
57
+
58
+ `assign`, `break`, `capture`, `case`, `comment`, `content_for`, `continue`, `cycle`,
59
+ `decrement`, `doc`, `echo`, `for`, `form`, `if`, `ifchanged`, `include`, `increment`, `#`,
60
+ `javascript`, `layout`, `liquid`, `paginate`, `raw`, `render`, `schema`, `section`,
61
+ `sections`, `style`, `stylesheet`, `tablerow`, `unless`.
62
+
63
+ With `--preview liquid-july-26`: `block` and `partial`. Without it they are unknown tags, as
64
+ on a live store.
65
+
66
+ ### The render budget
67
+
68
+ A render that does not end (nested `{% for i in (1..1000000000) %}`, in a file of the theme or
69
+ one the Admin API wrote) would hold a thread of the server, and its page, for ever. A render
70
+ stops once it spends its budget, as Ruby Liquid's resource limits stop it:
71
+
72
+ - a **render score** of 10,000,000: each body rendered counts its nodes (Ruby Liquid's
73
+ `increment_render_score`), and a loop over a range counts its items before it takes them;
74
+ - **10 seconds** of wall time.
75
+
76
+ The page then answers `500` with Liquid's error, `Liquid error: Memory limits exceeded`, and what
77
+ lsf counted; what was rendered is not shown, and nothing more renders. The pages of lsf's
78
+ fixture themes score 11 to 310 (a collection page the most): the limit is tens of thousands
79
+ of times what they need. Counting costs what it does not show: release builds before and after
80
+ it, the fixture theme and the demo store, 1000 renders a page interleaved by batches of 25,
81
+ medians of `Server-Timing: render` (2026-11-01, alternated twice): `/` 173 → 176 and
82
+ 174 → 175 µs, a collection 291 → 295 and 291 → 292 µs, a product 232 → 231 and 230 → 231 µs, a
83
+ search 139 → 141 and 139 → 139 µs.
84
+
85
+ Shopify documents neither number. What it documents: "You can do a maximum of 50 iterations
86
+ with a `for` loop" over a paginated array (`collection.products` and the others of `paginate`),
87
+ pages of 1 to 250 items, and no further than the 25,000th item. Its renderer stops a page that
88
+ takes too long, without publishing the time. lsf's limits are set far above what a real page
89
+ needs, so that only a render that would never end is stopped.
90
+
91
+ ## Liquid filters
92
+
93
+ Every one of the 154 filters of Shopify's reference: array, string, math, date, money, color,
94
+ font, media, HTML, URL, cart, customer, metafield and translation filters.
95
+ `applicable_disclosures` keeps every disclosure, as Shopify does when it cannot tell the
96
+ buyer's country. Among them
97
+ `image_url` and `image_tag` (with `srcset`, `sizes`, `preload`, focal points), the legacy
98
+ `img_url` family, `money` and its variants with every `money_format` placeholder, `t` with
99
+ pluralization and interpolation, `date` with locale formats, `json`, `structured_data`,
100
+ `payment_type_svg_tag`, `placeholder_svg_tag`, `font_face`, `color_*`, `metafield_tag`,
101
+ `default_pagination`, `link_to_*`, `highlight`, `time_tag`.
102
+
103
+ ## Objects
104
+
105
+ Global objects: `shop`, `cart`, `customer`, `request`, `routes`, `localization`, `template`,
106
+ `theme`, `settings`, `section`, `block`, `closest`, `linklists`, `collections`, `all_products`,
107
+ `pages`, `blogs`, `articles`, `images`, `metaobjects`, `metaobject` (on the pages of a
108
+ metaobject type), `product`, `collection`, `page`, `blog`, `article`, `policy`, `order`,
109
+ `gift_card`, `search`, `predictive_search`, `recommendations`, `paginate`, `form`, `forloop`, `tablerowloop`, `canonical_url`, `page_title`,
110
+ `page_description`, `page_image`, `handle`, `current_page`, `current_tags`, `shop_locale`,
111
+ `content_for_header`, `content_for_layout`, `country_option_tags`, `all_country_option_tags`,
112
+ `powered_by_link`, `additional_checkout_buttons`, `robots`, `self`.
113
+
114
+ With their related types: variants, options and option values (with swatches), media (images,
115
+ videos, external videos, 3D models), image presentation and focal points, collection filters
116
+ (with swatches) and sort options, links, addresses, orders and line items (a cart line has the
117
+ `index` shopify.dev leaves out: its 0-based position, which Horizon posts plus 1 as the `line` of
118
+ `/cart/change.js`; `/cart.js` has no such field), comments, metafields of every type (a
119
+ `color` is a color, a measurement has `type`, `value` and `unit`, a `money` prints as cents,
120
+ has the `currency` it is written in, and the money filters and `metafield_text` format it in
121
+ that currency, a reference is a GID or the key the data gives the entity
122
+ ([reference values](data-format.md#reference-values)), a `file_reference` is an image, a video or a file by its extension),
123
+ metaobjects, countries, languages (with `direction`, from the script of the language),
124
+ currencies, markets, fonts, colors, color schemes, quantity rules and quantity price breaks,
125
+ unit prices, selling plan groups, selling plans and their allocations, store availabilities and
126
+ locations, companies, company locations and company addresses.
127
+
128
+ ## Theme architecture
129
+
130
+ - JSON templates and Liquid templates, alternate templates (`?view=`, `template_suffix`),
131
+ layouts (`{% layout %}`, `"layout"` in JSON templates, `layout none`).
132
+ - Sections, static sections (with the settings stored in `settings_data.json` or the `default`
133
+ of their schema), section groups (`{% sections %}`), section and block settings with
134
+ defaults from the schema, `disabled` sections and blocks.
135
+ - Theme blocks (`blocks/*.liquid`), nested blocks, static blocks, `{% content_for 'blocks' %}`
136
+ and `{% content_for 'block' %}`, `closest.<resource>`.
137
+ - The id of a theme block (`block.id`, and its wrapper's `id="shopify-block-<id>"`) is
138
+ `<prefix>__<key>`, the key being its key in the JSON template or the `id` of a static block.
139
+ A block rendered again on the same page is numbered as on Shopify: the first render is
140
+ `<prefix>__<key>`, the second `<prefix>__<key>-1`, the third `<prefix>__<key>-2`. This
141
+ happens with a static block in a `for` loop (Horizon's product card), the blocks it holds,
142
+ or a second `{% content_for 'blocks' %}`. `section.blocks` gives the ids without a number.
143
+ A section rendered alone (`?section_id=`, `?sections=`) starts again from the first, and a
144
+ section asked for twice in `?sections=` is rendered once. The prefix is not Shopify's: do
145
+ not rely on its value. Shopify gives each block a prefix of its own, local-storefront one
146
+ per section, so two blocks that give a static block the same id (two collection links with
147
+ their `title`) give it the same id, and neither is numbered. A block defined in a section's
148
+ schema keeps its key as its id, and app blocks and blocks called with `{% block %}` are not
149
+ numbered.
150
+ - With `--preview liquid-july-26`, blocks called from a template or a layout, as in Shopify's
151
+ Skeleton theme: `{% block 'name', parameter: value %}content{% endblock %}`. The parameters
152
+ are read when the tag opens. The block sees them and its body, as `content` (the Liquid
153
+ reference) or `block.content` (the developer preview's guide), not the caller's variables;
154
+ a parameter named after a setting of its schema also sets `block.settings`, the body does
155
+ not. Arrays can be written in place and nested (`items: ['a', ['b', c]]`), not followed by a
156
+ lookup (`['a'].first` is a syntax error). The output is not wrapped in a `shopify-block`
157
+ element. A call from a section, a snippet, a block or the Liquid of a setting is an error,
158
+ as the reference says, and `npx local-storefront check` reports it (in `{% liquid %}` too).
159
+ - With `--preview liquid-july-26`, partials, `{% partial 'name' %}...{% endpartial %}`, and
160
+ `@shopify/partial-rendering` (`fetch`, `apply`, `refresh`, `get`, `getAll`). Shopify has not
161
+ published the markup and the requests of its own runtime yet, so local-storefront uses its
162
+ own: the region sits between `<!--shopify-partial name="..."-->` and
163
+ `<!--/shopify-partial-->`, and the runtime fetches the whole page and takes the regions out of
164
+ it. A theme that only uses the documented JavaScript API works the same; one that reads the
165
+ markup does not. A partial rendered several times (in a loop) is updated everywhere, the n-th
166
+ of the response replacing the n-th of the page (counted over the whole page, also when
167
+ `refresh(element)` updates only those inside an element); regions the response has fewer of
168
+ are emptied; one around table rows works too. `apply()` keeps focus, selection, the values of
169
+ form controls (hidden ones included, as Shopify documents: a theme updates what the server
170
+ changed) and scroll; scripts inside a region do not run again.
171
+ - Both tags are a developer preview on Shopify ("Liquid July '26"), on development stores that
172
+ turn it on, and may still change.
173
+ - Settings resolved to the objects Liquid expects: `image_picker`, `video`, `video_url`,
174
+ `product`, `collection`, `page`, `blog`, `article`, `metaobject`, `product_list`,
175
+ `collection_list`, `metaobject_list`, `link_list`, `color`, `color_palette`, `color_scheme`, `color_scheme_group`, `font_picker`,
176
+ `url`, `liquid`, `checkbox`, `number` and `range`; the other types are passed as written.
177
+ A `metaobject` setting holds the entry's handle, `<type>.<handle>`, `<type>/<handle>` or
178
+ `gid://shopify/Metaobject/<id>`. Dynamic sources are evaluated (`{{ product.title }}`,
179
+ `{{ closest.product.metafields.custom.x }}`).
180
+ - Locales: storefront translations and schema translations, pluralization rules by language,
181
+ the fallback to the default locale, Shopify's built-in translations. The translated content
182
+ of metaobjects, from the store data, on the routes of their language
183
+ ([data format](data-format.md#translations-of-metaobjects)); not yet that of products,
184
+ collections, pages or articles.
185
+ - Assets: `asset_url` and friends, `.liquid` assets, the `{% stylesheet %}` and
186
+ `{% javascript %}` bundles, `{% style %}`, preload headers.
187
+ - With `--app`, theme app extensions: app blocks (`{% render block %}`, `{% content_for 'blocks'
188
+ %}`, the `apps` section), app embed blocks (`head`, `compliance_head`, `body`), and in their
189
+ Liquid the extension's snippets, assets and translations, `app.metafields`, `javascript`,
190
+ `stylesheet`, `class`, `tag`, `enabled_on`, `disabled_on` and `available_if`
191
+ ([storefront](storefront.md#theme-app-extensions)).
192
+
193
+ ## What Shopify injects into pages
194
+
195
+ A storefront page is more than the theme's markup: Shopify adds scripts through
196
+ `{{ content_for_header }}`, before `</head>` and before `</body>`. local-storefront adds the
197
+ same ones, modelled on what a Shopify storefront serves, with every URL pointing at the local
198
+ server:
199
+
200
+ | What | Where | Local behaviour |
201
+ |---|---|---|
202
+ | `Shopify.shop`, `locale`, `currency`, `country`, `theme`, `cdnHost`, `routes.root`, `modules` | `content_for_header` | Same values and shape as on Shopify. `Shopify.designMode` is not set, as on a live storefront. |
203
+ | `shopify-features` JSON, `__st`, `shopify-digital-wallet` meta, `hreflang` alternates | `content_for_header` | Same shape. The Storefront API token is a placeholder. |
204
+ | `Shopify.loadFeatures`, `Shopify.autoloadFeatures` | `content_for_header` | `consent-tracking-api` installs a local `Shopify.customerPrivacy`. Features hosted by Shopify (`model-viewer-ui`, `shopify-xr`, ...) report an error to `onLoad`, which themes handle. |
205
+ | `Shopify.customerPrivacy` | after `loadFeatures` | Consent is kept in the browser. It has to be collected in the EEA, the United Kingdom and Switzerland (by `Shopify.country`): there, nothing is allowed and `shouldShowBanner()` is true until `setTrackingConsent` is called. A real store decides this in its privacy settings. |
206
+ | Import map for `@shopify/partial-rendering` | first in `content_for_header` | Only with `--preview liquid-july-26`, when a Liquid file of the theme has a `{% partial %}`. The module is served by local-storefront, see [Theme architecture](#theme-architecture). |
207
+ | `Shopify.PaymentButton` | `content_for_header` | Present; `init()` does nothing, since dynamic checkout buttons are drawn by Shopify. |
208
+ | The app's script tags (`asyncLoad`) | `content_for_header` | With the Admin API: each script tag of `scriptTagCreate` shown on the online store, loaded once the page has loaded, `shop=<domain>` added to its URL, as on Shopify. |
209
+ | `ShopifyAnalytics.meta` (`currency`, `page`, `product`), `trekkie` | before `</head>` | Same shape. Nothing is sent anywhere. |
210
+ | `Shopify.analytics.publish` | before `</head>` | Calls are kept in `Shopify.analytics.replayQueue`, which a test can read. |
211
+ | `Shopify.actions` (`getCart`, `updateCart`, `openCart`) | before `</body>` | The [standard storefront actions](https://shopify.dev/docs/api/storefront-events-and-actions), with `configure`, `isDefault`, the `shopify:cart:*` events and the default refresh of Horizon- and Dawn-style carts. They write to the local cart instead of the Storefront API. |
212
+ | `cart` cookie | cart endpoints | Set when something first goes into the cart, as on Shopify: `getCart` resolves with `cart: null` until then. |
213
+
214
+ Left out, because they only talk to Shopify's own services: analytics collection (web pixels,
215
+ performance monitoring), Shop Pay and sign-in with Shop, bot protection, and the MCP and
216
+ agent endpoints.
217
+
218
+ A theme that imports Shopify's standard events library from
219
+ `https://cdn.shopify.com/storefront/standard-events.js`, as Horizon does, loads it from
220
+ Shopify's CDN: it is a static file, but it does need network access.
221
+
222
+ ## Caching
223
+
224
+ Responses say how long they can be kept with the `Cache-Control` Shopify sends for the same
225
+ kind of response. The values were read from Shopify's own demo stores of Dawn and Horizon in
226
+ October 2026.
227
+
228
+ | Response | `Cache-Control` |
229
+ |---|---|
230
+ | Pages, sections, product, search and recommendation JSON, `robots.txt`, sitemaps | `private, max-age=0, must-revalidate` |
231
+ | What is not found, redirects | `private, no-store` |
232
+ | `/cart.js` | none |
233
+ | What changes the cart (`/cart/add.js`, `/cart/change.js`, ...) | `no-cache, no-store` |
234
+ | Theme assets, the compiled scripts, images, files | `public, max-age=31557600` |
235
+ | The compiled stylesheet, fonts | `public, max-age=31536000, immutable` |
236
+ | Shopify's feature loader, payment icons | `public, max-age=31536000` |
237
+ | `standard-actions.js` | `public, max-age=600, must-revalidate` |
238
+ | A theme asset that does not exist | `public, max-age=60` |
239
+
240
+ What the storefront answers itself also says `Vary: Accept`, and images too. A file that is
241
+ kept for a year has a URL that changes with its content: the `?v=` of a theme asset, which
242
+ for a `.liquid` asset follows the theme settings too, and the hash in the name of the
243
+ feature loader.
244
+
245
+ ## Minification
246
+
247
+ Shopify minifies the stylesheets and the scripts of a theme before it serves them, and so does
248
+ local-storefront. The rules are the ones read from Shopify's answers in October 2026, where the
249
+ 97 stylesheets and scripts of Dawn come out of esbuild to the byte.
250
+
251
+ - Whitespace and syntax are minified. Names are kept: a function or a variable is called the
252
+ same in the minified file.
253
+ - Stylesheets are also rewritten for older browsers, of the level of Safari 14: nesting is
254
+ flattened, the range syntax of media queries becomes `min-width` and `max-width`, recent
255
+ color functions get a fallback, and vendor prefixes are added.
256
+ - This applies to the assets, `.liquid` ones included once they are rendered, and to the
257
+ bundles built from the `{% stylesheet %}` and `{% javascript %}` tags.
258
+ - A declaration is merged into another only when the result sets the same properties: four
259
+ sides into `margin`, for instance. `translate`, `rotate` and `scale` stay apart from
260
+ `transform`, since they are applied before it and cascade on their own, and `font` and
261
+ `transition` stay where they are written, before or after `font-variant-numeric` or
262
+ `transition-behavior`, which they reset.
263
+ - A minified file ends with a link to its source map, which is served at the path of the
264
+ file followed by `.map`: the tools of a browser show the file as it is written. The link
265
+ carries the `?v=` the file was asked at (`base.css?v=123` links `base.css.map?v=123`), as
266
+ Shopify's does, so that the map's URL changes with the file.
267
+ - A file is served as written when its name ends with `.min.js` or `.min.css`, when the
268
+ minified file, link included, would not be lighter, or when it cannot be read as CSS or
269
+ JavaScript. There is no size limit below which a file is left alone: a file of a few hundred
270
+ bytes is minified when that saves bytes. In a stylesheet, a rule that cannot be read is kept
271
+ as written and the rest is minified.
272
+
273
+ `--no-minify` serves every file as it is written.
274
+
275
+ ## Images
276
+
277
+ The images of the store (`/cdn/shop/files/...`) and the ones among the theme's assets go
278
+ through a local stand-in for Shopify's image CDN. It resizes, crops and pads as the URL asks,
279
+ and chooses the format the way Shopify's CDN was seen to in October 2026:
280
+
281
+ - A client that names `image/webp` in its `Accept` header can get WebP, and one that names
282
+ `image/avif` as well can get AVIF. The weights (`;q=`) do not count. Browsers ask for
283
+ images that way; `fetch()`, `curl` and most HTTP clients do not, and get the format of the
284
+ file.
285
+ - The image is encoded in each format the client reads, and the lightest one is sent. This
286
+ is why a browser gets AVIF for one image and WebP for the next. The response says
287
+ `Vary: Accept`.
288
+ - An image of which nothing is asked (no size, no crop) is the file itself, unless a lighter
289
+ format can be sent.
290
+ - `format: 'pjpg'` gives a JPEG to every client. `format: 'jpg'` and `format: 'png'` name the
291
+ format for the clients that read neither WebP nor AVIF. An image with transparency does
292
+ not become a JPEG.
293
+ - `quality` is the quality of every format. Without it, the qualities are the ones with
294
+ which the files weigh about what Shopify's do: 85 for JPEG and AVIF, 90 for WebP.
295
+ - A WebP file is a JPEG for the clients that do not read WebP, or a PNG when it has
296
+ transparency.
297
+
298
+ Encoding an image takes a moment the first time it is asked for in a format: about a tenth
299
+ of a second for a product image as AVIF. It is then kept in memory until the file changes.
300
+
301
+ ## Preloading
302
+
303
+ `stylesheet_tag: preload: true`, `image_tag: preload: true` and `preload_tag` ask the
304
+ browser to load a file before it finds it in the page. As on Shopify, the tag itself does
305
+ not change: the page is answered with one `Link` header, written as Shopify writes it.
306
+
307
+ ```
308
+ Link: <//shop/cdn/shop/t/1/assets/base.css?v=1>; as="style"; rel="preload", <//shop/cdn/shop/files/hero.jpg?v=1&width=1600>; as="image"; rel="preload"; imagesrcset="//shop/cdn/shop/files/hero.jpg?v=1&width=800 800w, ..."; imagesizes="100vw"
309
+ ```
310
+
311
+ An image names the `srcset` and the `sizes` of its tag, so that the browser preloads the
312
+ size it is going to show. `preload_tag` repeats the attributes of its tag.
313
+
314
+ A font is asked for without credentials, whether the theme says so or not: a browser does
315
+ not use a preloaded font otherwise. `preload_tag: as: 'font'` writes `crossorigin="anonymous"`
316
+ in its tag and `crossorigin` in the header, as Shopify was seen to on stores that run
317
+ Horizon 4.
318
+
319
+ Shopify also reads the `<head>` it rendered and adds hints of its own, and so does
320
+ local-storefront: the stylesheets and the scripts that block rendering are preloaded, and the
321
+ other origins they come from are connected to. A stylesheet for print, a script with `async`,
322
+ `defer` or `type="module"`, and what is inside `<noscript>` do not block rendering.
323
+
324
+ The header names, in this order, as on the stores it was read from in October 2026:
325
+
326
+ 1. the other origins to connect to;
327
+ 2. what blocks rendering in the `<head>`, in the order of the document;
328
+ 3. the stylesheets the theme asks to preload;
329
+ 4. the stylesheet built from the `{% stylesheet %}` tags;
330
+ 5. the fonts, the images and the other files the theme asks to preload.
331
+
332
+ <a id="formatting-lsf-fmt"></a>
333
+
334
+ ## Formatting
335
+
336
+ `npx local-storefront fmt` is a port of Prettier with Shopify's Liquid plugin, and the goal is
337
+ the output they give, byte for byte. JavaScript is formatted by [Oxc](https://oxc.rs)'s
338
+ formatter, itself a port of Prettier's. The reference is Prettier 3.9.10 with
339
+ `@shopify/prettier-plugin-liquid` 1.11.2 (`tools/prettier/package.json`;
340
+ `mise run prettier:install` installs them).
341
+
342
+ How it is checked:
343
+
344
+ - **Liquid and HTML.** The 371 Liquid files of Shopify's Dawn and Horizon themes are formatted
345
+ by both, with what `<script>`, `<style>` and `{% schema %}` hold left alone
346
+ (`embeddedLanguageFormatting: off`), under four sets of options: widths of 120, 80, 40 and
347
+ 20 columns, tabs, both kinds of quotes, `bracketSameLine`, `singleAttributePerLine`, the
348
+ three values of `htmlWhitespaceSensitivity`. The 1,484 outputs are identical.
349
+ - **Everything at once.** The same 371 files, with CSS, JSON and JavaScript formatted too,
350
+ under six sets of options that include those of JavaScript (`semi`, `trailingComma`,
351
+ `arrowParens`, `quoteProps`, `objectWrap`, `bracketSpacing`, tabs, widths of 120, 80 and
352
+ 60): the 2,226 outputs are identical. Formatted again, they stay identical to what Prettier
353
+ gives.
354
+ - **The tests of the plugin.** Its 444 cases are replayed
355
+ (`mise run prettier:fixtures`): 443 give what Prettier gives. The other one is Markdown.
356
+ - **CSS.** The 68 style sheets of the two themes are formatted by both at 120, 80 and 40
357
+ columns: identical. Of the 157 files Prettier tests its own CSS printer with, which are edge
358
+ cases, 145 are identical through `{% stylesheet %}`.
359
+ - **66 cases** written for this project, one per feature and per option, are formatted by
360
+ Prettier into `crates/fmt/tests/golden` and compared on every test run
361
+ (`mise run prettier:golden` regenerates them).
362
+
363
+ To compare on your own theme, without writing to it:
364
+
365
+ ```bash
366
+ mise run prettier:compare -- path/to/theme
367
+ ```
368
+
369
+ What differs:
370
+
371
+ - **CSS inside JavaScript** (a template literal tagged `css`) is left as written. Prettier
372
+ formats it.
373
+ - **`experimentalTernaries`** is not supported.
374
+ - **JSON with comments** is left as written. Prettier formats it.
375
+ - **CSS that is more than CSS.** The selectors of `@nest`, `@custom-selector` and `@extend`,
376
+ the control rules of SCSS (`@if`, `@each`), the names CSS Modules export, a comment inside a
377
+ selector or inside the block of a custom property, a `//` comment in a value: these are
378
+ printed as written, or not as Prettier prints them.
379
+ - **Markdown** in `<script type="text/markdown">` is left as written.
380
+ - **Configuration written in JavaScript** (`prettier.config.js`, `.prettierrc.js`) is not
381
+ read, nor are TOML and JSON5 files. Other Prettier plugins do not apply.
382
+ - `requirePragma`, `insertPragma` and formatting a range of a file are not supported.
383
+
384
+ ## Shopify's strict Liquid parser
385
+
386
+ Shopify is moving every store from its lenient Liquid parser to a strict one.
387
+ `npx local-storefront check` reports the markup the strict parser rejects, and
388
+ `npx local-storefront check --fix` rewrites it into markup the strict parser accepts. Where what
389
+ the code meant is clear (a filter in a `render` argument, an `and` in an argument,
390
+ `('X' | downcase)`, `reversed` after `limit`, `=` in a condition, `and` after the value of a
391
+ `when`), the fix does it and says how the output changes. A `case` with such a `when` and an
392
+ `else` becomes `if`/`elsif`/`else`, so that the `else` runs when the condition is false; only
393
+ when its `when`s compare with distinct literals, a single `else` comes last and nothing but
394
+ whitespace precedes the first `when`, so that it renders the same otherwise. Elsewhere it
395
+ renders what the file renders today.
396
+
397
+ The rules are those of Shopify's reference implementation, the `liquid` gem 5.14, in
398
+ `error_mode: :strict2`: the `strict2_parse` of each tag and of outputs, ported tag by tag
399
+ (`crates/liquid/src/strict2`). Today, Shopify parses a tag with that parser first and falls
400
+ back on the lenient one when it fails; outputs and `{% when %}` go to the lenient parser
401
+ directly when they are not strictly valid. A fix that keeps the output is printed from what the
402
+ engine parses today, and is kept only when the strict parser accepts it and both parsers read
403
+ it the same as the original.
404
+
405
+ How it is checked:
406
+
407
+ - **liquid-spec runs most of its specs in `strict2` mode.** For the 6,314 that apply here,
408
+ `npx local-storefront check` finds a rejection exactly when the spec expects one, with the
409
+ gem's message, and every fix leaves nothing to reject (`crates/liquid/tests/strict2_spec.rs`).
410
+ Two recordings of Shopify's production expect a template that the gem rejects in `strict2`
411
+ mode (`{% if !name.first %}`): they are left out.
412
+ - **Fixes render the same with the gem.** 1,548 of those templates get a fix. The gem renders
413
+ each original in `:warn` mode, the mode Shopify renders themes in today, and each fix in
414
+ `:strict2` mode, with the data of the spec and again with data where every variable prints
415
+ its own path. The outputs are identical (`mise run oracle:strict2-fixes`).
416
+ - **Real themes.** Horizon and Dawn have nothing the strict parser rejects.
417
+
418
+ What to know:
419
+
420
+ - **Shopify's own tags** (`section`, `sections`, `layout`, `form`, `paginate`, `content_for`,
421
+ and `render` of an object, as in `{% render block %}`) are not in the gem. Shopify has not
422
+ published how its strict parser reads them: local-storefront reads them with the rules of the
423
+ standard tags, an expression without bare brackets, and a quoted name for `section`.
424
+ `{% block %}` and `{% partial %}` (behind `--preview liquid-july-26`) are not checked.
425
+ - **A fix writes the markup it replaces in the strict parser's own form:** a fixed tag gets
426
+ single spaces between its parts, `a . b` becomes `a.b`, and strings keep the quote the tag
427
+ used. Arguments written one per line stay one per line, and the rest of the file is left as
428
+ it is.
429
+ - **What the lenient parser passes to a snippet by mistake is left out when the snippet does
430
+ not read it.** In `{% render 'image', alt: image.alt | default: shop.name %}`, it reads
431
+ `default: shop.name` as an argument of `render`. Where the fix cannot apply the filter
432
+ instead, it leaves `default` out when `snippets/image.liquid` never mentions it (nor `self`):
433
+ a rendered snippet sees only its arguments.
434
+ - **`[name]` becomes `self[name]`.** The strict parser refuses a lookup that starts with a
435
+ bracket, and `self` is the variable it suggests instead. This reads the same unless the
436
+ theme assigns a variable named `self`.
437
+ - **Some markup has no fix:** what renders an error today (`{% if a = b %}`, where `=` is an
438
+ unknown operator), or what the strict parser has no way to write (an assigned variable named
439
+ `a.b`, a string that holds both kinds of quotes).
440
+ - **A fix that does what the code meant has no reference to be checked against**, since it
441
+ changes what renders on purpose. Each is checked to be accepted by the strict parser, and the
442
+ gem renders them as meant (a filtered `render` argument, an `and`, `('X' | downcase)`,
443
+ `reversed`, `==`). A variable such a fix adds gets a name the file does not use, but a
444
+ snippet included with `{% include %}` could still use that name.
445
+
446
+ ## Known differences
447
+
448
+ These are deliberate or not done yet. None of them raises a Liquid error.
449
+
450
+ | Area | Difference |
451
+ |---|---|
452
+ | Checkout | Not part of a theme. `/checkout` shows a summary of the cart. With `--app <dir>`, it is a local checkout that runs the Functions and shows the checkout UI extensions of a Shopify app: [checkout](checkout.md#what-it-does-not-do) lists what it leaves out (payments, pickup points, discount conditions and combinations, tax lines, the Storefront API). It supports API versions 2026-10 and 2027-01. |
453
+ | Admin API | With `--app`, a local GraphQL Admin API (2026-10, and 2027-01 with `--admin-versions`), OAuth and webhooks, for the backend of the app: every mutation is simulated ([what that means](admin-reference.md#what-lsf-simulates)). The REST Admin API and the Storefront API are not served: `404`. Work Shopify does in the background (a job, a bulk operation) is done within the request. There are no exchange rates (a market's manual rate, else 1) and orders have no duties. Its state is in memory, never written to disk: a start seeds it again from the store data. Each shop of `POST /__lsf/admin/shops` holds about 2 MB, and each order created some tens of KB (about 65 KB in a release build), until a reset or the shop is deleted. |
454
+ | Apps | Without `--app`, app blocks and app embed blocks render nothing; the page says so in `X-LSF-Reason`. `content_for_header` contains the scripts a theme relies on (`Shopify.*` globals, routes, the compiled asset tags) and the script tags apps created through the Admin API (an uninstall deletes them, as on Shopify), not Shopify's analytics scripts nor web pixels. |
455
+ | Theme app extensions | With `--app`, the app's blocks render ([storefront](storefront.md#theme-app-extensions)); the blocks of other apps render nothing. Their assets are served at `/cdn/extensions/<uid>/assets/<file>?v=<hash>`, where Shopify serves `cdn.shopify.com/extensions/<uid>/<version>/assets/<file>`. The `javascript` and `stylesheet` of a schema are added before `</head>` (`<script async>`, `<link rel="stylesheet">`); a section rendered alone (`?sections=`) does not add them. `compliance_head` embeds come before the `head` ones, before `</head>`, not first in the head. `app.metafields` reads `app.metafields` of the store data: what the backend writes to the app installation through the Admin API is not shown there. An embed turned on by `app.embeds` has the id of its name (`shopify-block-chat-bubble`). A resource setting with `autofill` is not filled in, and the objects Shopify withholds from extension Liquid are not withheld. `app dev`, which serves no theme, renders none. |
456
+ | Customer accounts | New customer accounts are hosted by Shopify, outside the theme. `/account` is a page of local-storefront that stands in for them: it shows who is logged in and lets you become anyone in the data. With `--app`, it is the customer account of the app instead: orders, profile and the pages of its customer account UI extensions ([checkout](checkout.md#customer-account)). Every account URL (`/account/login`, `/account/register`, `/customer_authentication/login`, ...) leads to it. Classic customer accounts are not simulated: the theme's `templates/customers` are never rendered, and its account forms (login, register, password reset, addresses) lead to `/account`. |
457
+ | Password page | `/password` always shows the theme's password page, and accepts `shop.password` (`password` by default). The storefront is never locked: nothing redirects to that page. On Shopify, a protected store redirects every page to it, and an open store redirects it to the home page. |
458
+ | Discounts | Carts and orders have no discounts: discount arrays are empty and totals are undiscounted (the checkout of an app applies the discounts its Functions give, nowhere else). `Shopify.actions.updateCart` reports every discount code as not applicable. |
459
+ | Standard actions | A change the cart refuses (unknown variant) resolves with `userErrors` whose code is `INVALID`, and leaves that line untouched. More than the stock resolves with the same `userErrors`, the line holding what is in stock: the Storefront API would return a warning instead. |
460
+ | Selling plans | Selling plan groups, plans and allocations are there, on products, variants, the cart and `/products/<handle>.js`. A group applies to every variant of a product, not to some of them. Prepaid plans are not modelled: `per_delivery_price` is the price. The line items of orders have no selling plan. The widget of a subscription app is an app block: it renders with `--app` when it is that app's, else the theme's own purchase options do. |
461
+ | Store pickup | `variant.store_availabilities` and `/variants/<id>?section_id=` are there. Locations come in the order of the data: Shopify sorts them by distance from the visitor. Choosing pickup happens at checkout, which is not part of a theme, so `order.pickup_in_store?` is always `false`. |
462
+ | Swatches | A swatch is given to option values by name, or to one value of a product. On Shopify it comes from the category metafield the option is linked to. `filter_value.image` (the `image` presentation of a filter) is not generated. |
463
+ | B2B | Companies, company locations, their addresses and metafields are there: `customer.b2b?`, `current_company`, `current_location`, `company_available_locations` and the link that changes location. Catalogs are not: a B2B customer sees the same products, prices and quantity rules as everyone. No payment terms, no store credit. |
464
+ | Markets | A country pays in its currency (its own, its market's, else the shop's), at its market's `rate` (1 when there is none: lsf has no exchange rates) or at its fixed `prices`, on the storefront and at the checkout. Shopify's rounding rules of converted prices (`.99`, `.95`) and price adjustments in percent are not applied. A market is served at `/` or under subfolders of its own (`/en-ca`); a market with a domain of its own is not served. Orders the checkout records in the Admin API keep the amounts of the checkout, without converting them back to the shop's currency. |
465
+ | Taxes and shipping | Not computed. |
466
+ | Search | A simple search over titles, descriptions, vendors, types, tags and SKUs. Ranking is not Shopify's: `relevance` keeps the order of the data. `sort_by` takes `price-ascending` and `price-descending` (any other value is `relevance`); pages and articles come after the products. |
467
+ | Recommendations | Products of the same collections, or the product's explicit `recommendations`. |
468
+ | Collection filters | Availability, price, vendor, product type, tag and variant options, with the swatches of option values. Metafield filters are not generated. |
469
+ | Placeholder artwork | `image`, `collection-2` and `lifestyle-2` are Shopify's illustrations: the three that are published under a free license. For the other names, `placeholder_svg_tag` draws generic artwork of the right proportions. |
470
+ | Fonts | Shopify's font library is not bundled. `font_face` and `font_url` generate the same markup and URLs; the files are blank unless provided in `files/fonts/`. |
471
+ | Shopify's shared assets | Requests under `/cdn/shopifycloud/` (payment buttons, model viewer UI, ...) get an empty file of the right type. The files of `shopify_asset_url` are served from the binary: `vendor/qrcode.js` is the qrcode.js library (MIT), as on Shopify; `shopify_common.js` has our own `Shopify.formatMoney`, `Shopify.resizeImage` and `Shopify.CountryProvinceSelector` (the province selector of an address form, filled from `data-provinces`); `option_selection.js`, `api.jquery.js`, `customer_area.js` and `currencies.js` only log a warning to the console, and `customer.css` is empty, because Shopify's code is proprietary; `gift-card/card.svg` is a drawing of our own, of the size of Shopify's, and `gift-card/add-to-apple-wallet.svg` a placeholder drawing. Any other name is a 404, logged by the server. |
472
+ | Theme editor | `request.design_mode` is always `false`, `Shopify.designMode` is not set, and `block.shopify_attributes` is empty. |
473
+ | Integers | 64-bit. Liquid on Shopify uses arbitrary-precision integers. |
474
+ | Byte strings | Strings are text (UTF-8). `base64_decode` of bytes that are not text replaces them with `�`, where Shopify carries the bytes on to the next filter. |
475
+ | Sitemaps | `/robots.txt` is rendered from `templates/robots.txt.liquid`, or with Shopify's default rules. `/sitemap.xml` links one sitemap per kind (products, pages, collections, blogs), in the primary language only and without paging. |
476
+ | Compression | Pages, styles, scripts and JSON are compressed with Brotli or gzip for the clients that accept it, at fast levels: the sizes are close to a storefront's, not equal. `--no-compression` turns it off. |
477
+ | Caching | Shopify keeps rendered pages in a cache of its own. An answer from that cache has an `ETag` and no `Cache-Control`; local-storefront always answers like a page that was just rendered. Files have no `Last-Modified`, so nothing is answered with `304 Not Modified`. |
478
+ | Image formats | The format of an image is chosen by Shopify's rule, with other encoders: the files weigh about what Shopify's do, not the same, so an image can come as AVIF where Shopify sends WebP, or the reverse. A GIF is never converted. The color profile and the metadata of a file are not kept when it is encoded. |
479
+ | Preloading | Shopify sends the `Link` header ahead of the page, as `103 Early Hints`, and with the pages it answers from its own cache. local-storefront sends it with every page. Shopify's header also asks to connect to its CDN (`cdn.shopify.com`), which serves nothing locally, and has a limit on its number of entries, which local-storefront does not have. |
480
+ | Minification | Shopify minifies with esbuild, local-storefront with other libraries (oxc for scripts, Lightning CSS for stylesheets): a file weighs about what it does on Shopify, without being the same to the byte. In a stylesheet, declarations can come in another order within a rule. `inset` is not replaced with `top`, `right`, `bottom` and `left`, and the width, style and color of the four sides of a border, all set in one rule, become `border`, which also resets `border-image`. Shopify's link to the source map carries the `?v=` of the request, local-storefront's does not: a file that minifying saves only a few dozen bytes of can be minified locally and served as written on Shopify. |
481
+ | Compiled scripts | Shopify runs the `{% javascript %}` of a section only on the pages that have that section. local-storefront runs them all on every page. |
482
+
483
+ Found a difference that is not in this table? It is a bug: a Liquid snippet and the HTML
484
+ Shopify renders for it are enough to reproduce it.
485
+
486
+ ### Differences with liquid-spec
487
+
488
+ The specs of liquid-spec that apply to the engine and do not pass, at the revision recorded in
489
+ `tools/liquid-spec/REVISION`:
490
+
491
+ | Specs | Reason |
492
+ |---|---|
493
+ | 15 | Integers beyond 64 bits: literals such as `18446744073709551615`, and the way Shopify's renderer reads such numbers from strings that end with a NUL character. |
494
+ | 3 | A date written with a UTC offset (`'2020-06-15 14:30:00 -0400' \| date: '%z'`) is shown in the shop's time zone, like every date of a storefront. The reference gem keeps the offset it was written with. |
495
+ | 2 | How the host stores files: a recording expects `{% include ".liquid" %}` not to find a file named `""`, another expects a renderer without a file system. |
496
+ | 1 | The spec expects `{{ #{1+1} }}` to print nothing. The gem (5.14) reports a variable that is not terminated, and so does the engine. |
497
+ | 1 | The recording injects a failure into Shopify's renderer and expects `Liquid error: internal`. |
498
+
499
+ The suite also has 26 sections of the Dawn theme with the HTML Shopify rendered. They are not
500
+ replayed: the specs hold neither the snippets, the translations nor the section settings the
501
+ HTML was rendered with. Those are in Dawn 5.0.0, so replaying them takes that theme and store
502
+ data written from the specs' environments.