local-storefront 0.2.0 → 0.3.0-rc.1
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.
- package/CHANGELOG.md +1299 -0
- package/README.md +14 -9
- package/bin/lsf.js +4 -1
- package/docs/README.md +59 -0
- package/docs/admin-reference.md +1406 -0
- package/docs/admin.md +1751 -0
- package/docs/agents.md +366 -0
- package/docs/architecture.md +303 -0
- package/docs/checkout.md +1609 -0
- package/docs/cli.md +873 -0
- package/docs/compatibility.md +475 -0
- package/docs/control-api.md +251 -0
- package/docs/data-format.md +943 -0
- package/docs/data-reference.md +1247 -0
- package/docs/formatting.md +119 -0
- package/docs/performance.md +266 -0
- package/docs/quickstart-admin-api.md +211 -0
- package/docs/quickstart-app.md +271 -0
- package/docs/quickstart-functions.md +207 -0
- package/docs/quickstart-theme.md +177 -0
- package/docs/storefront.md +210 -0
- package/docs/strict-liquid.md +178 -0
- package/docs/testing.md +590 -0
- package/llms.txt +39 -0
- package/package.json +11 -8
|
@@ -0,0 +1,475 @@
|
|
|
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, comments, metafields
|
|
117
|
+
of every type (a `color` is a color, a measurement has `type`, `value` and `unit`, a `money`
|
|
118
|
+
prints as cents, has the `currency` it is written in, and the money filters and `metafield_text`
|
|
119
|
+
format it in that currency, an `article_reference` is `blog/article` or the handle of an article
|
|
120
|
+
that only one blog has, a `file_reference` is an image, a video or a file by its extension),
|
|
121
|
+
metaobjects, countries, languages (with `direction`, from the script of the language),
|
|
122
|
+
currencies, markets, fonts, colors, color schemes, quantity rules and quantity price breaks,
|
|
123
|
+
unit prices, selling plan groups, selling plans and their allocations, store availabilities and
|
|
124
|
+
locations, companies, company locations and company addresses.
|
|
125
|
+
|
|
126
|
+
## Theme architecture
|
|
127
|
+
|
|
128
|
+
- JSON templates and Liquid templates, alternate templates (`?view=`, `template_suffix`),
|
|
129
|
+
layouts (`{% layout %}`, `"layout"` in JSON templates, `layout none`).
|
|
130
|
+
- Sections, static sections (with the settings stored in `settings_data.json` or the `default`
|
|
131
|
+
of their schema), section groups (`{% sections %}`), section and block settings with
|
|
132
|
+
defaults from the schema, `disabled` sections and blocks.
|
|
133
|
+
- Theme blocks (`blocks/*.liquid`), nested blocks, static blocks, `{% content_for 'blocks' %}`
|
|
134
|
+
and `{% content_for 'block' %}`, `closest.<resource>`.
|
|
135
|
+
- With `--preview liquid-july-26`, blocks called from a template or a layout, as in Shopify's
|
|
136
|
+
Skeleton theme: `{% block 'name', parameter: value %}content{% endblock %}`. The parameters
|
|
137
|
+
are read when the tag opens. The block sees them and its body, as `content` (the Liquid
|
|
138
|
+
reference) or `block.content` (the developer preview's guide), not the caller's variables;
|
|
139
|
+
a parameter named after a setting of its schema also sets `block.settings`, the body does
|
|
140
|
+
not. Arrays can be written in place and nested (`items: ['a', ['b', c]]`), not followed by a
|
|
141
|
+
lookup (`['a'].first` is a syntax error). The output is not wrapped in a `shopify-block`
|
|
142
|
+
element. A call from a section, a snippet, a block or the Liquid of a setting is an error,
|
|
143
|
+
as the reference says, and `npx local-storefront check` reports it (in `{% liquid %}` too).
|
|
144
|
+
- With `--preview liquid-july-26`, partials, `{% partial 'name' %}...{% endpartial %}`, and
|
|
145
|
+
`@shopify/partial-rendering` (`fetch`, `apply`, `refresh`, `get`, `getAll`). Shopify has not
|
|
146
|
+
published the markup and the requests of its own runtime yet, so local-storefront uses its
|
|
147
|
+
own: the region sits between `<!--shopify-partial name="..."-->` and
|
|
148
|
+
`<!--/shopify-partial-->`, and the runtime fetches the whole page and takes the regions out of
|
|
149
|
+
it. A theme that only uses the documented JavaScript API works the same; one that reads the
|
|
150
|
+
markup does not. A partial rendered several times (in a loop) is updated everywhere, the n-th
|
|
151
|
+
of the response replacing the n-th of the page (counted over the whole page, also when
|
|
152
|
+
`refresh(element)` updates only those inside an element); regions the response has fewer of
|
|
153
|
+
are emptied; one around table rows works too. `apply()` keeps focus, selection, the values of
|
|
154
|
+
form controls (hidden ones included, as Shopify documents: a theme updates what the server
|
|
155
|
+
changed) and scroll; scripts inside a region do not run again.
|
|
156
|
+
- Both tags are a developer preview on Shopify ("Liquid July '26"), on development stores that
|
|
157
|
+
turn it on, and may still change.
|
|
158
|
+
- Settings resolved to the objects Liquid expects: `image_picker`, `video`, `video_url`,
|
|
159
|
+
`product`, `collection`, `page`, `blog`, `article`, `metaobject`, `product_list`,
|
|
160
|
+
`collection_list`, `metaobject_list`, `link_list`, `color`, `color_palette`, `color_scheme`, `color_scheme_group`, `font_picker`,
|
|
161
|
+
`url`, `liquid`, `checkbox`, `number` and `range`; the other types are passed as written.
|
|
162
|
+
A `metaobject` setting holds the entry's handle, `<type>.<handle>`, `<type>/<handle>` or
|
|
163
|
+
`gid://shopify/Metaobject/<id>`. Dynamic sources are evaluated (`{{ product.title }}`,
|
|
164
|
+
`{{ closest.product.metafields.custom.x }}`).
|
|
165
|
+
- Locales: storefront translations and schema translations, pluralization rules by language,
|
|
166
|
+
the fallback to the default locale, Shopify's built-in translations.
|
|
167
|
+
- Assets: `asset_url` and friends, `.liquid` assets, the `{% stylesheet %}` and
|
|
168
|
+
`{% javascript %}` bundles, `{% style %}`, preload headers.
|
|
169
|
+
- With `--app`, theme app extensions: app blocks (`{% render block %}`, `{% content_for 'blocks'
|
|
170
|
+
%}`, the `apps` section), app embed blocks (`head`, `compliance_head`, `body`), and in their
|
|
171
|
+
Liquid the extension's snippets, assets and translations, `app.metafields`, `javascript`,
|
|
172
|
+
`stylesheet`, `class`, `tag`, `enabled_on`, `disabled_on` and `available_if`
|
|
173
|
+
([storefront](storefront.md#theme-app-extensions)).
|
|
174
|
+
|
|
175
|
+
## What Shopify injects into pages
|
|
176
|
+
|
|
177
|
+
A storefront page is more than the theme's markup: Shopify adds scripts through
|
|
178
|
+
`{{ content_for_header }}`, before `</head>` and before `</body>`. local-storefront adds the
|
|
179
|
+
same ones, modelled on what a Shopify storefront serves, with every URL pointing at the local
|
|
180
|
+
server:
|
|
181
|
+
|
|
182
|
+
| What | Where | Local behaviour |
|
|
183
|
+
|---|---|---|
|
|
184
|
+
| `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. |
|
|
185
|
+
| `shopify-features` JSON, `__st`, `shopify-digital-wallet` meta, `hreflang` alternates | `content_for_header` | Same shape. The Storefront API token is a placeholder. |
|
|
186
|
+
| `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. |
|
|
187
|
+
| `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. |
|
|
188
|
+
| 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). |
|
|
189
|
+
| `Shopify.PaymentButton` | `content_for_header` | Present; `init()` does nothing, since dynamic checkout buttons are drawn by Shopify. |
|
|
190
|
+
| 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. |
|
|
191
|
+
| `ShopifyAnalytics.meta` (`currency`, `page`, `product`), `trekkie` | before `</head>` | Same shape. Nothing is sent anywhere. |
|
|
192
|
+
| `Shopify.analytics.publish` | before `</head>` | Calls are kept in `Shopify.analytics.replayQueue`, which a test can read. |
|
|
193
|
+
| `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. |
|
|
194
|
+
| `cart` cookie | cart endpoints | Set when something first goes into the cart, as on Shopify: `getCart` resolves with `cart: null` until then. |
|
|
195
|
+
|
|
196
|
+
Left out, because they only talk to Shopify's own services: analytics collection (web pixels,
|
|
197
|
+
performance monitoring), Shop Pay and sign-in with Shop, bot protection, and the MCP and
|
|
198
|
+
agent endpoints.
|
|
199
|
+
|
|
200
|
+
A theme that imports Shopify's standard events library from
|
|
201
|
+
`https://cdn.shopify.com/storefront/standard-events.js`, as Horizon does, loads it from
|
|
202
|
+
Shopify's CDN: it is a static file, but it does need network access.
|
|
203
|
+
|
|
204
|
+
## Caching
|
|
205
|
+
|
|
206
|
+
Responses say how long they can be kept with the `Cache-Control` Shopify sends for the same
|
|
207
|
+
kind of response. The values were read from Shopify's own demo stores of Dawn and Horizon in
|
|
208
|
+
October 2026.
|
|
209
|
+
|
|
210
|
+
| Response | `Cache-Control` |
|
|
211
|
+
|---|---|
|
|
212
|
+
| Pages, sections, product, search and recommendation JSON, `robots.txt`, sitemaps | `private, max-age=0, must-revalidate` |
|
|
213
|
+
| What is not found, redirects | `private, no-store` |
|
|
214
|
+
| `/cart.js` | none |
|
|
215
|
+
| What changes the cart (`/cart/add.js`, `/cart/change.js`, ...) | `no-cache, no-store` |
|
|
216
|
+
| Theme assets, the compiled scripts, images, files | `public, max-age=31557600` |
|
|
217
|
+
| The compiled stylesheet, fonts | `public, max-age=31536000, immutable` |
|
|
218
|
+
| Shopify's feature loader, payment icons | `public, max-age=31536000` |
|
|
219
|
+
| `standard-actions.js` | `public, max-age=600, must-revalidate` |
|
|
220
|
+
| A theme asset that does not exist | `public, max-age=60` |
|
|
221
|
+
|
|
222
|
+
What the storefront answers itself also says `Vary: Accept`, and images too. A file that is
|
|
223
|
+
kept for a year has a URL that changes with its content: the `?v=` of a theme asset, which
|
|
224
|
+
for a `.liquid` asset follows the theme settings too, and the hash in the name of the
|
|
225
|
+
feature loader.
|
|
226
|
+
|
|
227
|
+
## Minification
|
|
228
|
+
|
|
229
|
+
Shopify minifies the stylesheets and the scripts of a theme before it serves them, and so does
|
|
230
|
+
local-storefront. The rules are the ones read from Shopify's answers in October 2026, where the
|
|
231
|
+
97 stylesheets and scripts of Dawn come out of esbuild to the byte.
|
|
232
|
+
|
|
233
|
+
- Whitespace and syntax are minified. Names are kept: a function or a variable is called the
|
|
234
|
+
same in the minified file.
|
|
235
|
+
- Stylesheets are also rewritten for older browsers, of the level of Safari 14: nesting is
|
|
236
|
+
flattened, the range syntax of media queries becomes `min-width` and `max-width`, recent
|
|
237
|
+
color functions get a fallback, and vendor prefixes are added.
|
|
238
|
+
- This applies to the assets, `.liquid` ones included once they are rendered, and to the
|
|
239
|
+
bundles built from the `{% stylesheet %}` and `{% javascript %}` tags.
|
|
240
|
+
- A minified file ends with a link to its source map, which is served at the path of the
|
|
241
|
+
file followed by `.map`: the tools of a browser show the file as it is written.
|
|
242
|
+
- A file is served as written when its name ends with `.min.js` or `.min.css`, when the
|
|
243
|
+
minified file would not be lighter, or when it cannot be read as CSS or JavaScript. In a
|
|
244
|
+
stylesheet, a rule that cannot be read is kept as written and the rest is minified.
|
|
245
|
+
|
|
246
|
+
`--no-minify` serves every file as it is written.
|
|
247
|
+
|
|
248
|
+
## Images
|
|
249
|
+
|
|
250
|
+
The images of the store (`/cdn/shop/files/...`) and the ones among the theme's assets go
|
|
251
|
+
through a local stand-in for Shopify's image CDN. It resizes, crops and pads as the URL asks,
|
|
252
|
+
and chooses the format the way Shopify's CDN was seen to in October 2026:
|
|
253
|
+
|
|
254
|
+
- A client that names `image/webp` in its `Accept` header can get WebP, and one that names
|
|
255
|
+
`image/avif` as well can get AVIF. The weights (`;q=`) do not count. Browsers ask for
|
|
256
|
+
images that way; `fetch()`, `curl` and most HTTP clients do not, and get the format of the
|
|
257
|
+
file.
|
|
258
|
+
- The image is encoded in each format the client reads, and the lightest one is sent. This
|
|
259
|
+
is why a browser gets AVIF for one image and WebP for the next. The response says
|
|
260
|
+
`Vary: Accept`.
|
|
261
|
+
- An image of which nothing is asked (no size, no crop) is the file itself, unless a lighter
|
|
262
|
+
format can be sent.
|
|
263
|
+
- `format: 'pjpg'` gives a JPEG to every client. `format: 'jpg'` and `format: 'png'` name the
|
|
264
|
+
format for the clients that read neither WebP nor AVIF. An image with transparency does
|
|
265
|
+
not become a JPEG.
|
|
266
|
+
- `quality` is the quality of every format. Without it, the qualities are the ones with
|
|
267
|
+
which the files weigh about what Shopify's do: 85 for JPEG and AVIF, 90 for WebP.
|
|
268
|
+
- A WebP file is a JPEG for the clients that do not read WebP, or a PNG when it has
|
|
269
|
+
transparency.
|
|
270
|
+
|
|
271
|
+
Encoding an image takes a moment the first time it is asked for in a format: about a tenth
|
|
272
|
+
of a second for a product image as AVIF. It is then kept in memory until the file changes.
|
|
273
|
+
|
|
274
|
+
## Preloading
|
|
275
|
+
|
|
276
|
+
`stylesheet_tag: preload: true`, `image_tag: preload: true` and `preload_tag` ask the
|
|
277
|
+
browser to load a file before it finds it in the page. As on Shopify, the tag itself does
|
|
278
|
+
not change: the page is answered with one `Link` header, written as Shopify writes it.
|
|
279
|
+
|
|
280
|
+
```
|
|
281
|
+
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"
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
An image names the `srcset` and the `sizes` of its tag, so that the browser preloads the
|
|
285
|
+
size it is going to show. `preload_tag` repeats the attributes of its tag.
|
|
286
|
+
|
|
287
|
+
A font is asked for without credentials, whether the theme says so or not: a browser does
|
|
288
|
+
not use a preloaded font otherwise. `preload_tag: as: 'font'` writes `crossorigin="anonymous"`
|
|
289
|
+
in its tag and `crossorigin` in the header, as Shopify was seen to on stores that run
|
|
290
|
+
Horizon 4.
|
|
291
|
+
|
|
292
|
+
Shopify also reads the `<head>` it rendered and adds hints of its own, and so does
|
|
293
|
+
local-storefront: the stylesheets and the scripts that block rendering are preloaded, and the
|
|
294
|
+
other origins they come from are connected to. A stylesheet for print, a script with `async`,
|
|
295
|
+
`defer` or `type="module"`, and what is inside `<noscript>` do not block rendering.
|
|
296
|
+
|
|
297
|
+
The header names, in this order, as on the stores it was read from in October 2026:
|
|
298
|
+
|
|
299
|
+
1. the other origins to connect to;
|
|
300
|
+
2. what blocks rendering in the `<head>`, in the order of the document;
|
|
301
|
+
3. the stylesheets the theme asks to preload;
|
|
302
|
+
4. the stylesheet built from the `{% stylesheet %}` tags;
|
|
303
|
+
5. the fonts, the images and the other files the theme asks to preload.
|
|
304
|
+
|
|
305
|
+
<a id="formatting-lsf-fmt"></a>
|
|
306
|
+
|
|
307
|
+
## Formatting
|
|
308
|
+
|
|
309
|
+
`npx local-storefront fmt` is a port of Prettier with Shopify's Liquid plugin, and the goal is
|
|
310
|
+
the output they give, byte for byte. JavaScript is formatted by [Oxc](https://oxc.rs)'s
|
|
311
|
+
formatter, itself a port of Prettier's. The reference is Prettier 3.9.10 with
|
|
312
|
+
`@shopify/prettier-plugin-liquid` 1.11.2 (`tools/prettier/package.json`;
|
|
313
|
+
`mise run prettier:install` installs them).
|
|
314
|
+
|
|
315
|
+
How it is checked:
|
|
316
|
+
|
|
317
|
+
- **Liquid and HTML.** The 371 Liquid files of Shopify's Dawn and Horizon themes are formatted
|
|
318
|
+
by both, with what `<script>`, `<style>` and `{% schema %}` hold left alone
|
|
319
|
+
(`embeddedLanguageFormatting: off`), under four sets of options: widths of 120, 80, 40 and
|
|
320
|
+
20 columns, tabs, both kinds of quotes, `bracketSameLine`, `singleAttributePerLine`, the
|
|
321
|
+
three values of `htmlWhitespaceSensitivity`. The 1,484 outputs are identical.
|
|
322
|
+
- **Everything at once.** The same 371 files, with CSS, JSON and JavaScript formatted too,
|
|
323
|
+
under six sets of options that include those of JavaScript (`semi`, `trailingComma`,
|
|
324
|
+
`arrowParens`, `quoteProps`, `objectWrap`, `bracketSpacing`, tabs, widths of 120, 80 and
|
|
325
|
+
60): the 2,226 outputs are identical. Formatted again, they stay identical to what Prettier
|
|
326
|
+
gives.
|
|
327
|
+
- **The tests of the plugin.** Its 444 cases are replayed
|
|
328
|
+
(`mise run prettier:fixtures`): 443 give what Prettier gives. The other one is Markdown.
|
|
329
|
+
- **CSS.** The 68 style sheets of the two themes are formatted by both at 120, 80 and 40
|
|
330
|
+
columns: identical. Of the 157 files Prettier tests its own CSS printer with, which are edge
|
|
331
|
+
cases, 145 are identical through `{% stylesheet %}`.
|
|
332
|
+
- **66 cases** written for this project, one per feature and per option, are formatted by
|
|
333
|
+
Prettier into `crates/fmt/tests/golden` and compared on every test run
|
|
334
|
+
(`mise run prettier:golden` regenerates them).
|
|
335
|
+
|
|
336
|
+
To compare on your own theme, without writing to it:
|
|
337
|
+
|
|
338
|
+
```bash
|
|
339
|
+
mise run prettier:compare -- path/to/theme
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
What differs:
|
|
343
|
+
|
|
344
|
+
- **CSS inside JavaScript** (a template literal tagged `css`) is left as written. Prettier
|
|
345
|
+
formats it.
|
|
346
|
+
- **`experimentalTernaries`** is not supported.
|
|
347
|
+
- **JSON with comments** is left as written. Prettier formats it.
|
|
348
|
+
- **CSS that is more than CSS.** The selectors of `@nest`, `@custom-selector` and `@extend`,
|
|
349
|
+
the control rules of SCSS (`@if`, `@each`), the names CSS Modules export, a comment inside a
|
|
350
|
+
selector or inside the block of a custom property, a `//` comment in a value: these are
|
|
351
|
+
printed as written, or not as Prettier prints them.
|
|
352
|
+
- **Markdown** in `<script type="text/markdown">` is left as written.
|
|
353
|
+
- **Configuration written in JavaScript** (`prettier.config.js`, `.prettierrc.js`) is not
|
|
354
|
+
read, nor are TOML and JSON5 files. Other Prettier plugins do not apply.
|
|
355
|
+
- `requirePragma`, `insertPragma` and formatting a range of a file are not supported.
|
|
356
|
+
|
|
357
|
+
## Shopify's strict Liquid parser
|
|
358
|
+
|
|
359
|
+
Shopify is moving every store from its lenient Liquid parser to a strict one.
|
|
360
|
+
`npx local-storefront check` reports the markup the strict parser rejects, and
|
|
361
|
+
`npx local-storefront check --fix` rewrites it into markup the strict parser accepts. Where what
|
|
362
|
+
the code meant is clear (a filter in a `render` argument, an `and` in an argument,
|
|
363
|
+
`('X' | downcase)`, `reversed` after `limit`, `=` in a condition, `and` after the value of a
|
|
364
|
+
`when`), the fix does it and says how the output changes. A `case` with such a `when` and an
|
|
365
|
+
`else` becomes `if`/`elsif`/`else`, so that the `else` runs when the condition is false; only
|
|
366
|
+
when its `when`s compare with distinct literals, a single `else` comes last and nothing but
|
|
367
|
+
whitespace precedes the first `when`, so that it renders the same otherwise. Elsewhere it
|
|
368
|
+
renders what the file renders today.
|
|
369
|
+
|
|
370
|
+
The rules are those of Shopify's reference implementation, the `liquid` gem 5.14, in
|
|
371
|
+
`error_mode: :strict2`: the `strict2_parse` of each tag and of outputs, ported tag by tag
|
|
372
|
+
(`crates/liquid/src/strict2`). Today, Shopify parses a tag with that parser first and falls
|
|
373
|
+
back on the lenient one when it fails; outputs and `{% when %}` go to the lenient parser
|
|
374
|
+
directly when they are not strictly valid. A fix that keeps the output is printed from what the
|
|
375
|
+
engine parses today, and is kept only when the strict parser accepts it and both parsers read
|
|
376
|
+
it the same as the original.
|
|
377
|
+
|
|
378
|
+
How it is checked:
|
|
379
|
+
|
|
380
|
+
- **liquid-spec runs most of its specs in `strict2` mode.** For the 6,314 that apply here,
|
|
381
|
+
`npx local-storefront check` finds a rejection exactly when the spec expects one, with the
|
|
382
|
+
gem's message, and every fix leaves nothing to reject (`crates/liquid/tests/strict2_spec.rs`).
|
|
383
|
+
Two recordings of Shopify's production expect a template that the gem rejects in `strict2`
|
|
384
|
+
mode (`{% if !name.first %}`): they are left out.
|
|
385
|
+
- **Fixes render the same with the gem.** 1,548 of those templates get a fix. The gem renders
|
|
386
|
+
each original in `:warn` mode, the mode Shopify renders themes in today, and each fix in
|
|
387
|
+
`:strict2` mode, with the data of the spec and again with data where every variable prints
|
|
388
|
+
its own path. The outputs are identical (`mise run oracle:strict2-fixes`).
|
|
389
|
+
- **Real themes.** Horizon and Dawn have nothing the strict parser rejects.
|
|
390
|
+
|
|
391
|
+
What to know:
|
|
392
|
+
|
|
393
|
+
- **Shopify's own tags** (`section`, `sections`, `layout`, `form`, `paginate`, `content_for`,
|
|
394
|
+
and `render` of an object, as in `{% render block %}`) are not in the gem. Shopify has not
|
|
395
|
+
published how its strict parser reads them: local-storefront reads them with the rules of the
|
|
396
|
+
standard tags, an expression without bare brackets, and a quoted name for `section`.
|
|
397
|
+
`{% block %}` and `{% partial %}` (behind `--preview liquid-july-26`) are not checked.
|
|
398
|
+
- **A fix writes the markup it replaces in the strict parser's own form:** a fixed tag gets
|
|
399
|
+
single spaces between its parts, `a . b` becomes `a.b`, and strings keep the quote the tag
|
|
400
|
+
used. Arguments written one per line stay one per line, and the rest of the file is left as
|
|
401
|
+
it is.
|
|
402
|
+
- **What the lenient parser passes to a snippet by mistake is left out when the snippet does
|
|
403
|
+
not read it.** In `{% render 'image', alt: image.alt | default: shop.name %}`, it reads
|
|
404
|
+
`default: shop.name` as an argument of `render`. Where the fix cannot apply the filter
|
|
405
|
+
instead, it leaves `default` out when `snippets/image.liquid` never mentions it (nor `self`):
|
|
406
|
+
a rendered snippet sees only its arguments.
|
|
407
|
+
- **`[name]` becomes `self[name]`.** The strict parser refuses a lookup that starts with a
|
|
408
|
+
bracket, and `self` is the variable it suggests instead. This reads the same unless the
|
|
409
|
+
theme assigns a variable named `self`.
|
|
410
|
+
- **Some markup has no fix:** what renders an error today (`{% if a = b %}`, where `=` is an
|
|
411
|
+
unknown operator), or what the strict parser has no way to write (an assigned variable named
|
|
412
|
+
`a.b`, a string that holds both kinds of quotes).
|
|
413
|
+
- **A fix that does what the code meant has no reference to be checked against**, since it
|
|
414
|
+
changes what renders on purpose. Each is checked to be accepted by the strict parser, and the
|
|
415
|
+
gem renders them as meant (a filtered `render` argument, an `and`, `('X' | downcase)`,
|
|
416
|
+
`reversed`, `==`). A variable such a fix adds gets a name the file does not use, but a
|
|
417
|
+
snippet included with `{% include %}` could still use that name.
|
|
418
|
+
|
|
419
|
+
## Known differences
|
|
420
|
+
|
|
421
|
+
These are deliberate or not done yet. None of them raises a Liquid error.
|
|
422
|
+
|
|
423
|
+
| Area | Difference |
|
|
424
|
+
|---|---|
|
|
425
|
+
| 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. |
|
|
426
|
+
| 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. |
|
|
427
|
+
| 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. |
|
|
428
|
+
| 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. |
|
|
429
|
+
| 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`. |
|
|
430
|
+
| 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. |
|
|
431
|
+
| 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. |
|
|
432
|
+
| Standard actions | A change the cart refuses (unknown variant, not enough stock) resolves with `userErrors` whose code is `INVALID`, and leaves that line untouched. The Storefront API would add what is in stock and return a warning. |
|
|
433
|
+
| 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. |
|
|
434
|
+
| 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`. |
|
|
435
|
+
| 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. |
|
|
436
|
+
| 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. |
|
|
437
|
+
| Markets | One price list: every country sees the shop currency's prices. The selected country changes `localization.country`, not prices. |
|
|
438
|
+
| Taxes and shipping | Not computed. |
|
|
439
|
+
| 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. |
|
|
440
|
+
| Recommendations | Products of the same collections, or the product's explicit `recommendations`. |
|
|
441
|
+
| Collection filters | Availability, price, vendor, product type, tag and variant options, with the swatches of option values. Metafield filters are not generated. |
|
|
442
|
+
| 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. |
|
|
443
|
+
| 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/`. |
|
|
444
|
+
| 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` and `Shopify.resizeImage`; `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. |
|
|
445
|
+
| Theme editor | `request.design_mode` is always `false`, `Shopify.designMode` is not set, and `block.shopify_attributes` is empty. |
|
|
446
|
+
| Integers | 64-bit. Liquid on Shopify uses arbitrary-precision integers. |
|
|
447
|
+
| 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. |
|
|
448
|
+
| 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. |
|
|
449
|
+
| 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. |
|
|
450
|
+
| 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`. |
|
|
451
|
+
| 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. |
|
|
452
|
+
| 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. |
|
|
453
|
+
| 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`. |
|
|
454
|
+
| 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. |
|
|
455
|
+
|
|
456
|
+
Found a difference that is not in this table? It is a bug: a Liquid snippet and the HTML
|
|
457
|
+
Shopify renders for it are enough to reproduce it.
|
|
458
|
+
|
|
459
|
+
### Differences with liquid-spec
|
|
460
|
+
|
|
461
|
+
The specs of liquid-spec that apply to the engine and do not pass, at the revision recorded in
|
|
462
|
+
`tools/liquid-spec/REVISION`:
|
|
463
|
+
|
|
464
|
+
| Specs | Reason |
|
|
465
|
+
|---|---|
|
|
466
|
+
| 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. |
|
|
467
|
+
| 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. |
|
|
468
|
+
| 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. |
|
|
469
|
+
| 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. |
|
|
470
|
+
| 1 | The recording injects a failure into Shopify's renderer and expects `Liquid error: internal`. |
|
|
471
|
+
|
|
472
|
+
The suite also has 26 sections of the Dawn theme with the HTML Shopify rendered. They are not
|
|
473
|
+
replayed: the specs hold neither the snippets, the translations nor the section settings the
|
|
474
|
+
HTML was rendered with. Those are in Dawn 5.0.0, so replaying them takes that theme and store
|
|
475
|
+
data written from the specs' environments.
|