local-storefront 0.3.0-rc.5 → 0.3.0-rc.6

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 CHANGED
@@ -3,6 +3,828 @@
3
3
  What changed in each version of `lsf`, for someone who uses it. The section of a version is
4
4
  what its [GitHub release](https://github.com/0xtlt/local-storefront/releases) says.
5
5
 
6
+ ## 0.3.0-rc.6 - 2026-11-14
7
+
8
+ Sixth release candidate of 0.3.0. It fixes about 130 differences from Shopify reported by an
9
+ audit of themes, the storefront, the checkout of an app, the Admin API and the command line,
10
+ each checked against what Shopify does. `lsf check` and `lsf validate` now refuse what Shopify
11
+ refuses on upload, and some Liquid objects, Ajax answers and Function limits read as a store's.
12
+ Some themes, theme tests and store data need a change: see Breaking changes.
13
+
14
+ ### Breaking changes
15
+
16
+ What to change when you upgrade from 0.3.0-rc.5.
17
+
18
+ - **`lsf check` reports the tags Shopify's parser refuses for where they are** (`liquid_syntax`,
19
+ which fails the check): `{% sections %}` in a section, `{% schema %}` in a snippet, twice,
20
+ empty or inside another tag, a theme block without `{% schema %}`, two `{% javascript %}` or
21
+ `{% stylesheet %}` tags in one file, `{% content_for 'blocks' %}` twice in a section or in a
22
+ snippet, `{% content_for 'foo' %}`, and a `{% content_for 'block' %}` without `id`, without
23
+ `type` or with a variable `type`. Shopify refuses such a file on upload, and every template
24
+ that places the section. A theme that checked clean with one of these now fails `check`:
25
+ move the tag where Shopify takes it.
26
+ - **`lsf check` reports the attributes of a section or theme block schema that Shopify refuses
27
+ on upload** (`invalid_schema`, which fails the check): a `name` of more than 25 characters,
28
+ an unknown attribute (`max_blocks` or `enabled_on` in a theme block), `@theme` with blocks the
29
+ section defines, two blocks of one `type`, `settings` on `@app`, a block defined in a theme
30
+ block, a preset without `name` or with a block the section does not define, preset `settings`
31
+ in a schema without settings, a `max_blocks` of `0` or `"5"`, a `limit` of 3, a section `tag`
32
+ of `span`, a `class` with `"` or `<`, and `enabled_on` or `disabled_on` that Shopify does not
33
+ take. Shopify refuses such a section and every template that places it; a theme that checked
34
+ clean with one of these now fails `check`.
35
+ - **`lsf check` reports the `config/settings_schema.json` and `config/settings_data.json` that
36
+ Shopify refuses on upload**, which fail the check: a `settings_schema.json` that is not a
37
+ list, a group without a `name`, an incomplete `theme_info`, a setting a section schema would
38
+ refuse (an unknown type, no `id`), one id in two groups, a `color_scheme_group` whose `role`
39
+ lacks a role or names no definition, a `color_palette` of more than 20 colors or with alpha,
40
+ more than 21 color schemes, and a `settings_data.json` whose `current` names no preset
41
+ (`invalid_settings_data`). Shopify keeps the previous version of such a file, so every
42
+ `settings.*` value stays on the old schema; lsf rendered with the new one. A theme that
43
+ checked clean with one of these now fails `check`.
44
+ - **`lsf check` reports the setting values of a JSON template, a section group or
45
+ `config/settings_data.json` that Shopify refuses on upload** (`invalid_setting_value`, which
46
+ fails the check): a `range` value out of its `min` and `max` (`99` for a range of 0 to 10) or
47
+ that is not a number, Liquid in a text setting that is not a dynamic source
48
+ (`{% if true %}yes{% endif %}`, `{{ page.title` unclosed), a `liquid` value over 50 kB. lsf
49
+ rendered the raw value; Shopify refuses the file. A theme that checked clean with one of these
50
+ now fails `check`.
51
+ - **`lsf check` reports the settings of a section or theme block schema that Shopify refuses on
52
+ upload** (`invalid_schema`, which fails the check): a `richtext` default that is not in
53
+ `<p>` or `<ul>` (`Hello`, `<div>`), an `inline_richtext` default with `<p>` or `<h2>`, a `url`
54
+ default other than `/collections` and `/collections/all`, a `color` default that is a name
55
+ (`red`), a `text_alignment` default of `middle`, a number as the `default` or `placeholder` of
56
+ a `text`, a `default` on a `product` setting, a `label` on a `header`, a `label` over 70
57
+ characters or an `info` over 500, an id with a space, two `select` options with one value, a
58
+ `range` of more than 101 steps (0 to 101 by 1, which lsf let through), a list `limit` outside
59
+ 1 to 50, a `video_url` that accepts another provider, and a `visible_if` that is not Liquid
60
+ starting with `{{`. Shopify refuses the section and every template that places it; a theme
61
+ that checked clean with one of these now fails `check`.
62
+ - **`lsf check` reports the JSON templates whose structure Shopify refuses on upload**
63
+ (`invalid_template`, which fails the check): an `order` entry that is not in `sections`, a
64
+ section that is not in `order`, a duplicate in `order`, no `order`, a section without `type`,
65
+ no section or more than 25, more than 50 blocks in a section or more of a type than its
66
+ `limit`, theme blocks nested deeper than 8 levels, an unknown attribute, a `wrapper` of
67
+ `span`, and a `layout` that is not a layout of the theme. Shopify keeps the previous version
68
+ of such a template, so its page never changes, where lsf rendered the new one. A theme that
69
+ checked clean with one of these now fails `check`.
70
+ - **`closest.<resource>` is the nearest resource setting, even an empty one.** A theme block
71
+ whose own `product` setting is unset no longer gets the page's product as `closest.product`:
72
+ it gets none, as on Shopify. Give the setting a value, or `{{ closest.product }}` as its
73
+ default, to keep the product of what the block is in.
74
+ - **`validate` refuses the metafield values Shopify refuses**: a `color` that is not a hex
75
+ code of six digits (`#fff`), a blank `single_line_text_field`, a `number_integer` beyond
76
+ ±9007199254740991, a `money` in another currency than the shop's, a `list.` of more than 128
77
+ values, and a `rich_text_field` with a node Shopify's rich text does not have (an `image`).
78
+ Each is an `invalid_metafield` error, where `validate` passed: write the value as a store
79
+ takes it.
80
+ - **A metafield used without `.value` acts as its value**, as on Shopify. A `boolean` metafield
81
+ that is `false` is falsy in `{% if product.metafields.custom.flag %}`, so a theme test that
82
+ expected the branch of `true` now gets the other one.
83
+ - **A handle that names nothing is truthy.** `{% if collections[handle] %}`,
84
+ `{% if linklists[section.settings.menu] %}` and `{% if section.settings.product %}` with a
85
+ deleted product now take the branch a store takes, the truthy one. Test the lookup with
86
+ `== empty` or `== blank`, as Shopify's docs advise.
87
+ - **`placeholder_svg_tag` refuses a name Shopify does not list, and `image_url` a `pad_color`
88
+ with `#`**, as on Shopify: `{{ 'nope' | placeholder_svg_tag }}` renders
89
+ `Unknown SVG placeholder 'nope'`, and `pad_color: '#ff0000'` renders
90
+ `Color padding must be between 000000 and ffffff`, where lsf drew a picture and padded. Use
91
+ a name of [Shopify's list](https://shopify.dev/docs/api/liquid/filters/placeholder_svg_tag)
92
+ and a color without `#` (`'ff0000'`).
93
+ - **`.first`, `.last` and `[n]` give nothing on reference lists, `product_list` settings and
94
+ `metaobjects.<type>.values`**, as on Shopify, where they are list drops. A theme that reads
95
+ `related.value.first` shows a blank on a store, and now on lsf too: use
96
+ `{% assign x = related.value | first %}` or a `for` loop. `list.single_line_text_field` and the
97
+ other lists that are not references stay arrays.
98
+ - **A page looks up at most 20 unique handles in `all_products`, and 20 in each metaobject
99
+ type**, handles that name nothing included, as on Shopify. The 21st, and every later lookup
100
+ in that list, renders `Exceeded maximum number of unique handles for all_products.` (or
101
+ `for <type>.`) in place of the entry. A page that worked with more handles now shows the
102
+ errors a store shows: loop over a collection, or over `metaobjects.<type>.values`, instead.
103
+ - **`localization.market` and `country.market` are no longer `primary` with id `1`.** They are
104
+ the market of the country, so theme tests that expect `primary` need the market's handle.
105
+ - **`paginate` and filter URLs put `page` first and sort the other parameters by name**, as
106
+ Shopify does: `/collections/all?page=2&sort_by=price-ascending`, no longer
107
+ `?sort_by=price-ascending&page=2`. Theme tests that compare these URLs as text need the new
108
+ order.
109
+ - **`product.variants` and the product JSON stop at 250 variants**, as on Shopify. A theme or a
110
+ test that reads variant 251 or later from `product.variants`, `/products/<handle>.js`, `.json`,
111
+ `/products.json` or `{{ product | json }}` no longer finds it: it would not on Shopify either.
112
+ - **`/products/<handle>.json` has Shopify's `.json` format**, no longer the `.js` body wrapped in
113
+ `product`. Code that read `description`, `type`, `tags` as an array or prices in cents from it
114
+ reads `body_html`, `product_type`, `tags` as one comma-separated string and prices as decimal
115
+ strings (`"20.00"`), as it must on Shopify. `/products/<handle>.js` does not change.
116
+ - **A `date_time` metafield written without an offset is in GMT**, as Shopify's list of data
117
+ types says, no longer in the shop's time zone: `"2026-03-05T14:30:00"` is
118
+ `2026-03-05T14:30:00+00:00`, and the `date` filter formats that time.
119
+ - **Handles follow Shopify's `handleize`.** A straight apostrophe is removed, underscores and the
120
+ letters of other scripts are kept: `Women's Tee` → `womens-tee` (was `women-s-tee`),
121
+ `summer_tee.json` → `summer_tee` (was `summer-tee`), `日本語 Tee` → `日本語-tee` (was `tee`).
122
+ This changes the handle, the URL and the id lsf derives for an entity of the store data whose
123
+ title or file name has one of these and that has no `handle` of its own, and the handles the
124
+ Admin API makes from titles. Give such an entity its old `handle` to keep its URL.
125
+ - **`/cart/add.js` answers `422` for an unknown variant**, no longer `404`, as the Ajax Cart API
126
+ reference shows: `{"status":422,"message":"Cart Error","description":"Cannot find variant"}`.
127
+ A theme test that expects `404` from `add.js` needs `422`.
128
+ - **`image_url` raises `invalid url input` on what is not an image**, as on Shopify: nil, a
129
+ product, a variant or a cart line without an image, an empty string, a URL of another site.
130
+ lsf printed an empty string (or a URL that led nowhere), so an unguarded `image_url` passed on
131
+ lsf and showed a Liquid error on the store; `render --strict` now fails on it. **`image_tag`
132
+ raises `image_url must define width or height`** when `image_url` has neither. Guard the
133
+ image (`{% if product.featured_image %}`) and give `image_url` a width.
134
+ - **`metafield_tag` and `metafield_text` print what Shopify prints, and nothing where it prints
135
+ nothing.** A list other than `list.single_line_text_field` and `list.metaobject_reference`
136
+ (`list.number_integer`, `list.product_reference`...) prints nothing, no longer a `<ul>` or a
137
+ sentence; so do a `language`, a `mixed_reference`, a `customer_reference` and a
138
+ `company_reference` with `metafield_tag`. `metafield_tag` of a `metaobject_reference` or a
139
+ `list.metaobject_reference` without `field:` is a Liquid error, `metafield_tag requires a
140
+ 'field' parameter for metafields of type 'metaobject_reference'`, no longer the handle. A
141
+ `rating` prints `3.5`, no longer `3.5 / 10.0`. A theme that shows these on lsf shows nothing,
142
+ or the error, on a store: give a metaobject its `field:`, and loop over the other lists.
143
+ - **A loop over a list of references or `metaobjects.<type>.values` gets the first 50 items**,
144
+ as on Shopify, no longer all of them; `.count` and `.size` are still the total. Wrap the loop
145
+ in `paginate` to reach the others. `paginate` of such a list now shows one page of it, up to
146
+ 250 items, and pages it with a parameter of its own, `page_` and 8 hexadecimal digits
147
+ (`/products/widget?page_1a2b3c4d=2`), as Shopify does, no longer `?page=2`. Code that builds
148
+ `?page=` for these lists reads `paginate.page_param` instead.
149
+ - **`| json` refuses a metafield, `metaobject.system` and `metaobjects.<type>`**, as Shopify
150
+ does: `{{ product.metafields.custom.x | json }}` gives
151
+ `{"error":"json not allowed for this object"}`, no longer the stored value. Write
152
+ `{{ product.metafields.custom.x.value | json }}`. The deprecated types `string`, `integer` and
153
+ `json_string` still give their value. A `number_decimal` value gives a string, as on Shopify:
154
+ `{{ m.value | json }}` is `"1.5"` and a `list.number_decimal` is `["1.5","2.25"]`, no longer
155
+ `1.5` and `[1.5,2.25]`.
156
+ - **An unset text or color setting is `""`, no longer nil**, as on Shopify. A `text`,
157
+ `textarea`, `richtext`, `inline_richtext`, `html`, `color` or `color_background` setting with
158
+ no value and no `default`, and a cleared `color`, is still `blank` and prints nothing, but it
159
+ is truthy: `{% if section.settings.color %}` now takes the branch a store takes. Test it with
160
+ `!= blank`.
161
+ - **`?sections=` with more than five sections answers `400` and renders nothing**, as on
162
+ Shopify, and a cart request that bundles more than five gives `"sections": null`. A theme or a
163
+ test that asks for six sections at once gets nothing on a store, and now on lsf too: ask for
164
+ five at most.
165
+ - **A section rendered at `/variants/<id>?section_id=` has no `product`**, as on Shopify. A
166
+ pickup availability section that reads `product` renders empty there on a store, and now on
167
+ lsf too: read `product_variant` and `product_variant.product`.
168
+ - **A path typed in a `url` setting keeps its path on a localized route**, as on Shopify. On
169
+ `/fr/` or a market subfolder (`/en-be/`), `/pages/contact` prints `/pages/contact`, no longer
170
+ `/fr/pages/contact`; a value from a dynamic source (`{{ request.path }}`) is printed as it is,
171
+ no longer prefixed a second time (`/fr/fr`). A picked resource (`shopify://…`), the home page
172
+ (`/`) and the schema's `default` (`/collections`, `/collections/all`) keep the prefix. A theme
173
+ test that expected `/fr/pages/contact` from a typed path needs `/pages/contact`.
174
+ - **`country_option_tags` and `all_country_option_tags` start with a `---` option**, as on
175
+ Shopify: `<option value="---" data-provinces="[]">---</option>`. A theme test that compares
176
+ their first option, or counts them, sees one more.
177
+ - **`localization.available_countries` is sorted by the countries' names**, in the language of
178
+ the page, as on Shopify (`Belgium`, `Canada`, `United States`; `Belgique`, `Canada`,
179
+ `États-Unis` on `/fr/`), no longer in the order of the store data. A theme test that expects
180
+ the order of `localization.countries` needs the order of the names.
181
+ - **A search puts the products that cannot be bought last**, as Shopify does by default
182
+ (`unavailable_products` is `last`): on `/search` after every other result, pages and articles
183
+ included, and in `/search/suggest.json` after the other products. `hide` leaves them out of the
184
+ results and of `search.results_count`, and `show` keeps them in their place. lsf ignored the
185
+ option. A theme test that expects a sold-out product in its place among the results needs
186
+ `options[unavailable_products]=show`.
187
+ - **`/search/suggest.json` answers `422` to a missing `q` and to invalid parameters**, as the
188
+ Predictive Search API reference says: a missing or blank `q`, a `resources[limit]` that is not
189
+ a number, or a `resources[type]`, `resources[limit_scope]`, `resources[options][fields]` or
190
+ `resources[options][unavailable_products]` it does not take, with
191
+ `{"status":422,"message":"Invalid parameter error","description":…}` and no `resources`. It
192
+ answered `200`. A limit out of range is still brought between 1 and 10. Code that requests
193
+ suggestions for an empty input must not read `resources` from the answer.
194
+ - **`/recommendations/products.json` answers Shopify's errors**, as the Product
195
+ Recommendations API reference lists them: `422` `Invalid parameter error` for a missing
196
+ `product_id`, one that is not a number, and an `intent` other than `related` or
197
+ `complementary`; `404` `Product not found` for an id that is not a product of the online store
198
+ (unknown, a draft, not published). It answered `200` with no products, and echoed a wrong
199
+ `intent`. A recommendations block whose request fails now takes its error path on lsf too.
200
+ - **A cart line of a product without an image has a `featured_image` of nulls**, no longer
201
+ `null`, as on Shopify: `{"aspect_ratio":null,"alt":null,"height":null,"url":null,"width":null}`
202
+ in `/cart.js`, `/cart/add.js` and `{{ item | json }}`. A test of the cart's JSON that expects
203
+ `"featured_image": null` needs the object; `image` is still `null`.
204
+ - **Liquid `item.properties` of a cart line is a list of pairs, without `.size`**, as on Shopify.
205
+ `{{ item.properties | json }}` gives `[["Engraving","Hi"],["_hidden","x"]]` (`[]` for none),
206
+ no longer an object, and `{{ item.properties.size }}` prints nothing, so
207
+ `{% if item.properties.size > 0 %}` is false, as on a store. A loop, `item.properties.Engraving`,
208
+ `{{ item.properties | size }}` and `item.properties == empty` (shopify.dev's test) are
209
+ unchanged. `/cart.js` keeps `properties` as an object.
210
+ - **Private cart attributes are hidden from the theme and the Ajax API**, as on Shopify: an
211
+ attribute whose name starts with `__` is no longer in Liquid `cart.attributes`,
212
+ `{{ cart | json }}`, `/cart.js`, nor in the answer of `/cart/update.js` and the other cart
213
+ endpoints. The checkout and the order still have it. A theme test that read a `__` attribute
214
+ back from the cart needs another way, as it would on a store.
215
+ - **`/products/<handle>.js` has no `content` key**, as on Shopify: read `description`.
216
+ `{{ product | json }}` keeps `content`, as shopify.dev's `json` filter shows.
217
+ - **`/cart/update.js` with a variant id that names no variant answers `404`** with
218
+ `{"status":404,"message":"Cart Error","description":"Cannot find variant"}` and changes nothing,
219
+ as the Ajax Cart API reference shows. It answered `200`, so a theme's error branch never ran.
220
+ The cart page's form still goes back to the cart, without adding the unknown variant.
221
+ - **`/collections/<unknown>/products.json` answers `200` with `{"products":[]}`**, no longer
222
+ `404`, as a store does.
223
+ - **`/search/suggest` and `/recommendations/products` without `section_id` answer `422`**, as
224
+ HTML, no longer `400` as text: `Invalid parameter error: Missing section_id param` for
225
+ predictive search, an empty body for recommendations, as a store does.
226
+ - **The `reason` of a `buyerJourney.intercept` block is no longer shown to the buyer**, as on
227
+ Shopify, which keeps it for its own debugging. The page shows the block's `errors` alone; a
228
+ block with no `errors` stops the payment with no message, as on a store, so an extension that
229
+ relied on `reason` now leaves the buyer stuck here too: show why in its UI, or pass `errors`.
230
+ A browser test that read the reason on the page reads the Overview of the Functions dialog,
231
+ which lists each block with its `reason`.
232
+ - **A Function run that fails no longer blocks the order**, as on Shopify, which ignores the
233
+ output of a failed run ("When a function run fails, Shopify ignores that run's output"). A
234
+ discount, delivery customization or payment customization that traps, runs past the
235
+ instruction limit or answers out of its schema changes nothing, and `complete` places the
236
+ order where it answered `422`; so does a failed validation or cart transform by default. Only
237
+ a validation or a cart transform whose owner has `blockOnFailure: true` blocks the order when
238
+ it fails: `validationCreate`, `validationUpdate` and
239
+ `cartTransformCreate` set it, and so does the new `block_on_failure` of an entry of
240
+ `checkout.functions` in the store data. The failure stays the run's error: the Functions badge
241
+ is red, and `lsf app check` and `lsf app function run` still fail on it. A scenario whose
242
+ `expect.errors` listed `` Function `x` failed: … `` now gets no such error, unless the Function
243
+ blocks on failure.
244
+ - **A payment customization's `orderReviewAdd` submits a B2B checkout for review**, as on
245
+ Shopify, where it was accepted and ignored. On a B2B checkout, `complete` places no order: the
246
+ Admin API gets a draft order (`draft_orders/create`, not `orders/create`), the answer and the
247
+ thank-you page have `draftOrder` instead of `order`, and the state lists the requirement in
248
+ `reviews`. A test that read an order from such a checkout reads the draft. On a D2C checkout
249
+ the operation still adds nothing, as Shopify documents.
250
+ - **A metafield whose value is over 10,000 bytes reads `null` in a Function's input**, as
251
+ Shopify returns none. lsf gave the whole value, so a Function that kept a large configuration
252
+ in a metafield worked here and lost it on a store.
253
+ - **A Function whose input query is over Shopify's input query limits fails to run**, as Shopify
254
+ refuses it: a query of more than 3,000 bytes without its comments, a list of more than 100
255
+ elements given to an argument or a variable, or a calculated cost over 30
256
+ (`Query has complexity of 33, which exceeds max complexity of 30`, from Shopify's table:
257
+ `hasAnyTag` 3, a `metafield` 3, another leaf 1...). The run fails as any failed run does, and
258
+ `lsf app check` fails on it; such a query ran on lsf and passed, and could not be deployed. Lower its cost as Shopify's reference advises: one
259
+ metafield that holds a JSON object, each costly field selected once.
260
+ - **A Function run over Shopify's resource limits fails**, as on Shopify; lsf checked the
261
+ instruction count only. The limits: a compiled module over 256 KiB (Shopify does not deploy it,
262
+ and `app build` warns), a run whose linear memory grows past 10 MiB (the grow stops it), an
263
+ input over 128 kB or an output over 40 kB (as JSON, for a cart of up to 200 lines). The
264
+ input, output and instruction limits grow in proportion to the lines of a cart of more than
265
+ 200, as Shopify's dynamic limits do. Such a run passed `lsf app check`; it now fails it, and
266
+ its output is ignored, as a failed run's is.
267
+ - **A UI extension needs the `network_access` capability to `fetch` a server out of the shop**,
268
+ as on Shopify. A checkout or customer account extension without it that calls its backend
269
+ (another origin than lsf's) now gets a rejected `fetch` (a `TypeError` that names the
270
+ capability) and a warning in the console, where the call went through. Add
271
+ `network_access = true` to `[extensions.capabilities]`, as the extension needs on a store.
272
+ - **In the customer account, `shopify.i18n.translate()` of a missing key gives Shopify's
273
+ `MISSING KEY` text**, `MISSING KEY for locale "en": "nope"`, and logs
274
+ `ExtensionUsageError: translate(): MISSING KEY for locale "en": "nope"`, as a store does. It gave
275
+ the key, which often looks right. A test that read the key from a customer account extension
276
+ needs the key in the extension's locale files. Checkout extensions still get the key.
277
+ - **A `t:` key saved as a setting's value is printed as it is**, as on Shopify, which
278
+ translates the attributes of a schema only. A JSON template, a section group or
279
+ `config/settings_data.json` that saves `"text": "t:sections.heading.default"` now shows
280
+ `t:sections.heading.default`, where lsf showed the translation: a value left over from a
281
+ preset shows the raw key on a store too. A schema `default` of `t:…` is still translated.
282
+ - **`t` no longer takes `default:`, and a missing plural form is `Translation missing`**, as on
283
+ Shopify. `{{ 'x.nope' | t: default: 'DEF' }}` printed `DEF`; Shopify has no such argument and
284
+ prints `Translation missing: en.x.nope`. A `count` whose form is missing and that has no
285
+ `other` (`{ "one": … }` with a count of 2) printed another form; it is now
286
+ `Translation missing` too. A theme that relied on either now shows what the storefront shows.
287
+
288
+ ### Fixed
289
+
290
+ #### Themes and the storefront
291
+
292
+ - **A `color_scheme` theme setting gives its colors.** `settings.scheme.settings.background` and
293
+ `settings.scheme.settings.text.rgb` were empty for a `color_scheme` setting of
294
+ `config/settings_schema.json`; they now give the colors of the selected scheme, as a section's
295
+ `color_scheme` setting already did. `settings.color_schemes['scheme-1']` gives one scheme by
296
+ id, as on Shopify; a `for` loop over `settings.color_schemes` is unchanged.
297
+ - **Trailing commas and unknown escapes in theme JSON are read as Shopify reads them.** A
298
+ `{% schema %}`, a JSON template, a locale, `config/settings_schema.json` or
299
+ `config/settings_data.json` with a comma before `}` or `]`, or an escape such as `\“`, now
300
+ gives its values (`\“` is `“`), where lsf dropped the file: settings lost their defaults, a
301
+ template rendered an error, a locale fell back to the default language. `check` no longer
302
+ says Shopify refuses such a file: it warns (`lenient_json`) and exits `0`. A file that starts
303
+ with a byte order mark is still an error, as on Shopify, and a render now names it in a
304
+ warning rather than leave its settings or translations out silently.
305
+ - **`closest.product` follows block and section settings.** In a theme block, `closest.product`
306
+ (and `closest.collection`, `article`, `blog`, `page`) is the block's own resource setting,
307
+ else its parent blocks', else the section's, and only then the template's resource, as
308
+ Shopify resolves it. A product card in a group of another product shows that product, and a
309
+ section's product setting reaches its blocks outside a product page too.
310
+ - **A metafield without `.value` compares, filters and sorts as its value.** For the text,
311
+ number, `boolean`, `date` and `date_time` types, `if`, `unless`, `==`, `>`, the math filters,
312
+ `date`, `size`, and `sort`, `where` and `sum` on a field of metaobjects read the value:
313
+ `{% if f.finish == 'gloss' %}`, `{{ f.n | plus: 1 }}`, `{{ f.day | date: '%d.%m.%Y' }}` and
314
+ `{{ metaobjects.faq.values | sort: 'question' }}` give what a store gives, no longer `false`,
315
+ `1`, the unformatted date or "cannot sort values of incompatible types". A reference, a list,
316
+ a `json`, a `color`, a `money`, a `rating` or a measurement stays the metafield.
317
+ - **`page_title` and `page_description` are the search engine listing.** A product, a
318
+ collection, a page, a blog or an article with the metafields `global.title_tag` and
319
+ `global.description_tag`, where Shopify keeps its SEO title and description, gives them as
320
+ `page_title` and `page_description`, as on Shopify, where lsf gave its title and description.
321
+ - **A color prints as it is stored, and a metaobject is found in any case.** A `color`
322
+ setting, the colors of a color scheme and a `color` or `list.color` metafield stored as
323
+ `#FF00aa` print `#FF00aa`, compare equal to it and give it to `json` and `metafield_tag`, as on
324
+ Shopify, where lsf lower-cased them. `metaobjects.FAQ['ONE']` finds the entry `faq/one`, and
325
+ `/pages/faq/ONE` is its page, with its URL in lower case, as on Shopify.
326
+ - **`date` and `date_time` metafields print the stored string.** `{{ m.value }}`, `{{ m }}` and
327
+ `{{ m.value | json }}` give `2026-03-05` and `2026-03-05T14:30:00+01:00`, as on Shopify, where
328
+ a `date` printed `2026-03-05 00:00:00 -0500` and a `date_time` was moved to the shop's time
329
+ zone. The `date` filter formats them as before, and the `datetime` of `metafield_tag` is the
330
+ stored value, as Shopify's reference shows.
331
+ - **`current_tags` holds the tags as the products write them.** On
332
+ `/collections/<handle>/premium+snow`, `current_tags` is `Premium` and `Snow`, as on Shopify,
333
+ where lsf gave the URL's `premium` and `snow`: the tag filter of Shopify's guide,
334
+ `{% if current_tags contains tag %}` over `collection.all_tags`, now marks the active tags.
335
+ - **A handle lookup that finds nothing is Shopify's `empty` object, no longer nil.**
336
+ `collections['x']`, `all_products['x']`, `pages['x']`, `blogs['x']`, `linklists['x']` and
337
+ `product.metafields.<namespace it lacks>` are truthy and `== empty`, and
338
+ `collections['x'].products.size`, `blogs['x'].articles.size` and `linklists['x'].links.size`
339
+ print `0`, no longer nothing. A `product`, `collection`, `page`, `blog`, `article` or
340
+ `link_list` setting whose handle no longer exists is truthy, `== blank` and `== nil`, and
341
+ prints the handle.
342
+ - **`collections` lists the store's own collections, without `all`.** A loop over
343
+ `collections`, `| size` and `{% paginate collections %}` no longer include the catch-all
344
+ `all` collection, which `collections.all` still finds, as on Shopify. `collections.size`,
345
+ `collections.first` and `collections.last` give the size, first and last collection of that
346
+ list, where they printed nothing; so do `pages` and `blogs`.
347
+ - **A resource setting prints its handle, no longer `ProductDrop`.** A `product`, `collection`,
348
+ `page`, `blog`, `article` or `link_list` setting printed as is (`{{ section.settings.product }}`,
349
+ or in a class, a URL or a data attribute) gives the handle of what it names, and its `.size`
350
+ is the length of that handle, as on Shopify. Its properties, its `json` and its comparisons
351
+ are the resource's, as before.
352
+ - **`canonical_url` keeps the tags and the search.** A tag page is its own canonical
353
+ (`/collections/things/premium+snow`), and the search, vendor and type pages keep their `q`
354
+ (`/search?q=widget`, `/collections/vendors?q=acme+labs`, lower-cased), as on Shopify, where
355
+ lsf gave the bare collection URL and `/search`.
356
+ - **An unset setting is what Shopify gives.** A `select` or a `radio` with no value and no
357
+ `default` is its first option, a `text_alignment` is `left`, and the text and color settings
358
+ are `""`, where lsf gave nil for all of them: `text-{{ section.settings.align }}` is
359
+ `text-left`, as on a store, no longer `text-`.
360
+ - **Reference lists, `product_list` settings and `.values` have `.count`.** The value of a
361
+ `list.product_reference` or `list.metaobject_reference` metafield, a `product_list` setting
362
+ and `metaobjects.<type>.values` give their length with `.count` (0 for an unset
363
+ `product_list`), and print `ProductListDrop` or `MetaobjectListDrop`. The `first` and `last`
364
+ filters, `for` and `paginate` reach their items.
365
+ - **A `product_list` setting fed by a list metafield holds its products.** With
366
+ `"{{ product.metafields.custom.related.value }}"` as its value in a JSON template, a
367
+ "related products" section shows the metafield's products, in their order, no longer
368
+ nothing.
369
+ - **`localization.market` is the market of the visitor's country**, as on Shopify: under
370
+ `/en-be/`, the market of Belgium, with its `handle`, its `metafields` and the `id` the Admin
371
+ API and the Functions give it. `country.market` in `localization.available_countries` is each
372
+ country's own market. A country in no market is a market of its own, named after the country
373
+ (`france`), as at the checkout.
374
+ - **`shop.money_format` and `shop.money_with_currency_format` are in the market's currency.** A
375
+ visitor in a market with another currency gets Shopify's format of that currency
376
+ (`€{{amount_with_comma_separator}}` and `€{{amount_with_comma_separator}} EUR` for EUR), the
377
+ one the money filters already used, so the scripts a theme hands `shop.money_format` print the
378
+ right symbol. `shop.currency` stays the store's.
379
+ - **Money metafields are in the market's currency.** In a market with another currency and a
380
+ `rate`, a `money` metafield in the store currency is converted as prices are: `| money`,
381
+ `| money_with_currency`, `.value` (cents), `.value.currency`, `metafield_tag` and
382
+ `metafield_text` give `€7,20 EUR` for 8.00 USD at `0.9`, as on Shopify. One written in another
383
+ currency keeps it.
384
+ - **Metafield values as the Admin API returns them are read.** Shopify stores every value as a
385
+ string, so `validate` now takes a `boolean` written `"true"` or `"false"`, a `number_integer`
386
+ written `"42"` (in a list too), a unit written as the API's enum (`KILOGRAMS`) and the
387
+ deprecated types `string`, `integer` and `json_string`, where it gave errors. In Liquid,
388
+ `"true"` is `true`, no longer `false`, a `KILOGRAMS` unit is `kilograms`, and a deprecated
389
+ type gives its value directly. A `boolean` that `validate` still refuses shows as it is
390
+ written, not as `false`.
391
+ - **A section rendered for a variant sees what Shopify gives it.** At
392
+ `/variants/<id>?section_id=` (and `?sections=`), the URL Dawn's pickup availability fetches,
393
+ `product` and `variant` are nil, `template.name` is empty, `request.page_type` is `variants`
394
+ and `request.path` has no trailing slash, where lsf gave the product and its template.
395
+ `product_variant` is the variant, as before.
396
+ - **A section rendered alone links to the page, not to itself.** With `?section_id=` or
397
+ `?sections=`, `paginate.next.url`, `paginate.previous.url`, `paginate.parts[].url`,
398
+ `default_pagination` and the `url_to_add` and `url_to_remove` of filters leave those
399
+ parameters out, as Shopify does: infinite scroll, Ajax pagination and Ajax filters fetch the
400
+ next page, not a bare section, and a script that adds its own `section_id` no longer sends two.
401
+ - **The Section Rendering API renders five sections at most.** `?sections=` with six distinct
402
+ ids answers `400` with an empty body, as Shopify documents, where lsf rendered all six;
403
+ `x-lsf-reason` says why. A `/cart/add.js`, `change.js`, `update.js` or `clear.js` that bundles
404
+ six still changes the cart and answers `200`, with `"sections": null`, as a store does. A
405
+ section asked for twice counts once.
406
+ - **A product with more than 250 variants shows what Shopify shows.** `product.variants` and a
407
+ `for` loop over it give the first 250, the Ajax API's product JSON holds 250, and
408
+ `product.variants_count` is still the total. A variant picker that misses variants past 250 on
409
+ Shopify misses them locally too.
410
+ - **`/products/<handle>.json` answers what a store answers.** `{"product": …}` with `id`,
411
+ `title`, `body_html`, `vendor`, `product_type`, `created_at`, `handle`, `updated_at`,
412
+ `published_at`, `template_suffix`, `published_scope`, `tags`, `variants` (with `product_id`,
413
+ `position`, `option1` to `option3`, `grams`, `image_id`, `price_currency` and decimal prices),
414
+ `options` and `images` as objects with ids, and `image`. An app or a theme script that reads it
415
+ works locally as it will on Shopify.
416
+ - **`handleize` and `handle` give what a store gives.** `Women's T-Shirts & Tops` →
417
+ `womens-t-shirts-tops`, `A_B` → `a_b`, `Ελληνικά Русский 日本語` →
418
+ `ελληνικά-русский-日本語`, `😀 smile 😀` → `😀-smile-😀`, `Straße Œuvre fine` →
419
+ `strasse-oeuvre-fine`. Ids, anchors, tag and filter URLs, and collections looked up by a
420
+ handleized product type, now match the store's.
421
+ - **`product.options_by_name` finds an option in any case**, as Shopify documents ("the name is
422
+ case-insensitive"): `options_by_name['color']`, `['COLOR']` and `.color` find the option
423
+ `Color`. Swatch and size-chart code that looks options up by a lower-case name works locally.
424
+ - **`product.options_with_values | json` gives each option.** An option in `json` is
425
+ `{"name":"Color","position":1,"values":["Black","White"]}`, as on Shopify, where it was
426
+ `null`: variant pickers that hand the options to JavaScript get their names and values.
427
+ - **`weight_with_unit` and `variant.weight_in_unit` print weights as Shopify does.** Grams are
428
+ a whole number (`3 g`), and the other units a decimal number at the precision Shopify keeps:
429
+ kilograms to 3 places, pounds to 2, ounces to 1, with its `.0` (`10.0 lb`, `4.536 kg`,
430
+ `0.001 kg`, `1.0 oz`). A variant of 4536 g in `lb` gives `weight_in_unit` `10.0`, no longer
431
+ `10.000168212706047`. Shipping weight lines read as on a store.
432
+ - **`{{ variant | structured_data }}` prints the variant's schema.org `Product`**, with its
433
+ `Offer`, as a store does, no longer nothing: `@id` (`…?variant=<id>#variant`), `image`,
434
+ `name` (`<title> - <variant>`, the title alone for a product without options), `offers` and
435
+ `sku`. It is the item of the product's `hasVariant`. JSON-LD of the selected variant can be
436
+ checked locally.
437
+ - **A `/cart/add.js` of several `items` with one refused adds none of them.** A sold-out item or
438
+ an unknown variant among them answers `422` and leaves the cart as it was, as a store does: a
439
+ bundle that meets a sold-out pick no longer leaves the items before it in the cart.
440
+ - **`/cart/add.js` with an unknown variant id answers `422`**, with `Cannot find variant`, as
441
+ Shopify does. `Shopify.actions.updateCart` still reports it on the line's `merchandiseId`.
442
+ - **Measurement and rating metafields print as a store prints them.** `{{ m.value }}` of a
443
+ `dimension`, `weight` or `volume` gives `1.5 cm`, `2.25 kg`, `500 mL` (two decimals at most),
444
+ and of a `rating` `3.5`, where lsf printed a Ruby hash. `.value.value` is a decimal (`500.0`),
445
+ and `.value.unit` the unit Shopify's Liquid gives: `kg`, `cm`, … for `kilograms`,
446
+ `centimeters`, …, and `mL`, `cL` and `gal` for `ml`, `cl` and `us_gal`. `metafield_tag` and
447
+ `metafield_text` show these units too.
448
+ - **An image of `files/` named by its path has the size of its file.** An `image_picker` setting
449
+ (`shopify://shop_images/hero.jpg`), `images['hero.jpg']` and a file metafield now read the
450
+ width and height of `files/hero.jpg`, as product images do: a 2048 x 2560 file gives
451
+ `2048x2560`, an `aspect_ratio` of `0.8` and `image_url: width: 300 | image_tag` a
452
+ `height="375"`, as on Shopify. It was 1200 x 1200 unless the data declared a size. The size
453
+ declared in `files` of `store.json` is now used only when the file is missing.
454
+ - **A product image without alt text has the product's title as its alt**, as on Shopify:
455
+ `product.featured_image.alt`, `product.featured_media.alt`, `product.images[n].alt`, the `alt`
456
+ of `image_tag` and `featured_image.alt` of a line in the cart's JSON gave an empty string. An
457
+ image with alt text keeps it, and `media.preview_image.alt` stays empty, as on Shopify.
458
+ - **`product.media | json` gives media objects.** `{{ product.media | json }}` and
459
+ `{{ product.featured_media | json }}` give each image as Shopify does (`id`, `alt`,
460
+ `position`, `preview_image`, `media_type`, `src`, `width`, `height`, `aspect_ratio`), as
461
+ `{{ product | json }}` already did, where they gave its URL. `product.images` and
462
+ `product.featured_image` stay URLs.
463
+ - **`image_url` with a `width` and a `height` crops from the center**, as on Shopify:
464
+ `image_url: width: 300, height: 200` gives `?crop=center&height=200&v=…&width=300`, and the
465
+ server crops the image to that box, where lsf fitted it inside. `crop: nil` and `pad_color`
466
+ still leave `crop` out, as on Shopify.
467
+ - **`collection.all_products_count` counts the whole collection on a tag page**, where it
468
+ counted the tagged products, and `collection.sort_options` has `most-relevant` ("Most
469
+ relevant"), second of nine, as on Shopify.
470
+ - **`image_url` takes a `width` or a `height` over 5760 as 5760**, where it raised, names `Width`
471
+ and `Height` with a capital in its error, and leaves out `format: 'jpg'` of a JPEG, which
472
+ converts nothing, as Shopify does. An external video's `aspect_ratio` is `1.77`, the video's,
473
+ as on Shopify, no longer its thumbnail's (4:3).
474
+ - **`page_title` of a search counts its results and escapes the quotes**:
475
+ `Search: 2 results found for &quot;widget&quot;`, as on Shopify.
476
+ - **`structured_data` of a product writes the SKU Shopify writes**: a one-variant product's,
477
+ and no `sku` or `image` for a variant that has none, where lsf wrote `""` and `null`.
478
+ - **`image_tag` builds the `srcset`, `width` and `height` Shopify builds.** The width given to
479
+ `image_url` is the last entry of the default `srcset` (`width: 400` gives `352w, 400w`, where
480
+ lsf gave `352w`); the entries of `widths:` above that width are dropped; no entry left, no
481
+ `srcset`. `width` and `height` are the size `image_url` asks for, even beyond the file's:
482
+ `width: 3000` on a 1600 px wide image gives `width="3000"`, where lsf gave `1600`.
483
+ - **`image_tag: preload: true` writes `loading="eager"`**, as on Shopify, even with
484
+ `loading: 'lazy'`: a preloaded hero image was lazy on lsf. `loading` now comes right after
485
+ `width` and `height`, wherever the theme passes it, as Shopify writes it.
486
+ - **`image_url` raises on what is not an image, and `image_tag` without a size**, as Shopify
487
+ does: see Breaking changes.
488
+ - **`lsf render` and `lsf profile` answer a market's subfolders.** `/en-be/`, `/fr-be/products/…`
489
+ and a section rendered alone there (`/en-be/?section_id=…`) render in the market's country,
490
+ currency and language, as `serve` answers them and as Shopify serves a market's subfolders,
491
+ where they were the theme's 404 (and `render --strict` failed on the routes
492
+ `routes --all` lists).
493
+ - **Security: the localization form no longer redirects to another host.** `POST /localization`
494
+ with `return_to=https://other.example/x` or `//other.example/x` answered a `302` to that host
495
+ (an open redirect). It now keeps the path and redirects on lsf's own host (`/x`, or `/en-be/x`
496
+ for a country served there), as Shopify redirects to the path on the shop's domain. A theme
497
+ that puts an absolute URL in `return_to` stays on the local server, as it stays on the shop.
498
+ - **The localization form sets the `localization` cookie, and the cart sets `cart_currency`.**
499
+ `POST /localization` with a country the store sells to sets `localization=<country>`, and the
500
+ pages and Ajax answers of a visitor who has a cart set `cart_currency=<currency>`, the currency
501
+ of the answer (`EUR` under `/en-be/`), as Shopify sets both. A script or a currency converter
502
+ that reads them gets a value locally too.
503
+ - **`metafield_text` HTML-escapes text, as `metafield_tag` does.** A `single_line_text_field`,
504
+ a `multi_line_text_field`, the items of a `list.single_line_text_field` and the `field` of a
505
+ metaobject give `Tom &amp; &quot;Jerry&quot; &lt;b&gt;x&lt;/b&gt; l&#39;été`, as on Shopify,
506
+ no longer the raw text: `<b>` in a value shows as text, and the text is safe in an attribute.
507
+ - **`metafield_tag` prints links, URLs, variants and rich text as Shopify does.** A `link` is
508
+ `<a href="https://example.com/docs" class="metafield-url">Docs &amp; more</a>`, no longer a
509
+ Ruby hash, and its `metafield_text` is empty. A `url` is shown without its scheme
510
+ (`example.com/a?b=1&amp;c=2`), a `variant_reference` is a link to the variant, a link of rich
511
+ text keeps its `target` and `title`, and a metaobject's `field:` and a list of metaobjects have
512
+ the classes of a `single_line_text_field`. `metafield_text` of a rich text puts each block on a
513
+ line of its own (`Care` and `Wipe dry.`), and that of a `file_reference` is the file's URL, as
514
+ on a store.
515
+ - **`| json` gives a metaobject entry and a metafield namespace.** `{{ metaobjects.faq.two | json }}`,
516
+ `{{ product.metafields.custom.faq.value | json }}` and a list of references give the value of
517
+ each field by key, `{"order":2,"question":"Can I return an item?"}` and an array of those, and
518
+ `{{ product.metafields.custom | json }}` the values of the namespace, as on Shopify, no longer
519
+ `null`. Themes that hand metaobjects to JavaScript get them locally. The text of rich text is
520
+ HTML-escaped in its JSON, as on Shopify (`Care &amp; use`).
521
+ - **Every country has its name and its continent.** The 237 countries Shopify lists have the
522
+ English name Shopify gives them (`Andorra`, `Kenya`, `Qatar`, `Uruguay`, where lsf printed
523
+ `AD`, `KE`, `QA`, `UY` for all but 63 of them), their currency, and the continent
524
+ `country.continent` gives (`Europe`, `North America`, `Central America`...), which was empty.
525
+ `all_country_option_tags` lists all 237, by name, where it listed the 63. The data is
526
+ Shopify's own address data (Shopify/worldwide), as the provinces already were. Bulgaria pays
527
+ in euros by default, as it has since 2026.
528
+ - **Countries have their names in the language of the page.** On `/fr/`, `country.name`,
529
+ `localization.country.name`, `shop.address.country`, an address's `summary` and
530
+ `format_address` give `États-Unis`, `Belgique`, `Suisse` (on `/de/`: `Vereinigte Staaten`,
531
+ `Belgien`, `Schweiz`), where lsf gave the English names on every page. The names are those
532
+ of Shopify's address data (Shopify/worldwide, from the Unicode CLDR) in the 38 languages lsf
533
+ names. A country with a `name` in the store data keeps it.
534
+ - **Contextual templates and section groups apply to their market and to B2B buyers.** A
535
+ `templates/index.context.europe.json` or `sections/header-group.context.europe.json` with
536
+ `"context": {"market": "europe"}` and its `"parent"` now adapts the page on the market's
537
+ subfolders (`/en-be/`, `/fr-be/`), where lsf always rendered the parent: its settings,
538
+ `disabled`, added sections and blocks and its `order` go on the parent, as Shopify applies
539
+ them. `/` and the other markets keep the parent. `"context": {"b2b": true}` applies to a
540
+ customer who buys for a company (`customer.b2b?`). The Section Rendering API renders the same
541
+ sections as the page.
542
+ - **`/search/suggest.json` gives products and collections a `featured_image`**, as Shopify
543
+ does: an object with `alt`, `aspect_ratio`, `height`, `url` and `width`, all `null` when there
544
+ is no image. A product also has `variants` (empty), and a collection its `published_at`.
545
+ Predictive search code that reads `product.featured_image.url` no longer throws on lsf.
546
+ - **Storefront filters work on the search page.** `search.filters` lists the filters of the
547
+ products found (availability, price, product type, vendor, variant options, with their
548
+ counts), as `collection.filters` does, and `filter.v.*` and `filter.p.*` narrow the results.
549
+ A filter leaves out the pages and articles, as Shopify does. `search.filters` was always empty
550
+ and the parameters were ignored, so a filter sidebar on the search page rendered nothing.
551
+ - **`item.featured_image.url` of an image-less product reads `null` in the cart's JSON**, where
552
+ it threw a `TypeError` on lsf: the line's `featured_image` is an object of nulls, as Shopify
553
+ sends it.
554
+ - **`/products/<handle>.js` has the product's `url` and the variants' `quantity_price_breaks`**,
555
+ as on Shopify. `url` carries the language and market prefix of the request
556
+ (`/fr-be/products/tee`), so a quick view or a recently-viewed list links to the product
557
+ instead of `undefined`. `quantity_price_breaks` is `[]` for a variant without breaks, else
558
+ `{"minimum_quantity", "price"}` in cents.
559
+ - **A product's URL works in any case**: `/products/TEE` and `/products/TEE.js` answer the product
560
+ `tee`, as on Shopify, where they were `404`. Collection URLs keep their case, as on Shopify.
561
+ - **Storefront search reads `-` and `NOT`**: `tee -blue` and `tee NOT blue` list the matches of
562
+ `tee` without those of `blue`, as Shopify's search syntax says, where lsf found nothing.
563
+ `title:<word>` finds the products with that word in their title, not only those titled
564
+ `<word>`, and a `type` the store does not know is ignored: with no type it knows, the search
565
+ covers them all, as on Shopify.
566
+
567
+ - **A Liquid or JSON file that is not UTF-8 renders as on Shopify.** A section, a snippet or a
568
+ JSON template saved in Latin-1 or Windows-1252 (`é` as the byte `0xE9`) was treated as
569
+ missing: `is not a valid section type`, `Could not find asset`, or no template at all. It is
570
+ now read as Shopify CLI uploads it, each byte that is not UTF-8 becoming `�`, so the page
571
+ shows `caf�`, as a store does.
572
+ - **`date` and `time_tag` without a format print the locale's `default` format**, as
573
+ `format: 'default'` does on Shopify (`Thursday, March 5, 2026 at 2:08 am -0500`). `date` was
574
+ a Liquid error (`wrong number of arguments`), and `time_tag` printed `date_at_time`.
575
+ - **`{% paginate … by %}` a nil value pages by 20**, as Shopify does: an unset section setting
576
+ or an undefined variable (`by section.settings.per_page`) was a Liquid error, `invalid
577
+ integer`, and the block did not render.
578
+ - **`t` keeps a placeholder it has no argument for, as Shopify does.** Given no argument at all,
579
+ `{{ 'x.hello' | t }}` prints `Hello {{ name }}`; given others but not `name`, it prints
580
+ Shopify's error, `I18n Error: Missing interpolation value "name" for "Hello {{ name }}"`. lsf
581
+ printed `Hello `, which hid the mistake. `name: nil` still prints `Hello `.
582
+ - **`t` with `count` picks the plural form Shopify picks**: the `zero` form for 0 (and 0.0) in
583
+ every language, else the CLDR category of the number, decimals included (`1.5 items` in
584
+ English, `1.5 article` in French) and the `many` of a million in French, Spanish, Italian,
585
+ Portuguese and Catalan, else `other`. A text count (`'0'`) takes `other`. lsf rounded decimals down and
586
+ never used `zero` or `many`.
587
+ - **`t` prints a group of keys as Shopify does**, as a Ruby hash
588
+ (`{"key"=>"deep value"}`, HTML-escaped unless the key ends in `_html`): a key that names a
589
+ group, and plural forms without `count`, which printed the `other` form with an empty count.
590
+ A key with a leading dot (`.plain`) is read as `plain`, where lsf looked up `en..plain`.
591
+ - **`date` and `time_tag` read a date as a storefront does**: a slash date day first
592
+ (`03/01/2026` is January 3, `31/12/2026` December 31, and `12/31/2026` no date), as Ruby reads
593
+ it, and a Unix timestamp with a fraction (`1772323200.9`, or as a text), which lsf printed as
594
+ it was.
595
+ - **`{% paginate %}` of nil names the expression** in its error,
596
+ `Array 'nothing_here.products' is not paginateable.`, where lsf named an empty array.
597
+ - **Past the last page, `paginate.previous` is the last page** (and so is the "Previous" link of
598
+ `default_pagination`), as on Shopify; it was the page before the one asked for, past the end
599
+ too.
600
+ - **`{% render name %}` with a snippet's name in a variable prints Shopify's syntax error** in its
601
+ place, `<!-- Syntax error in tag 'render' - Template name must be a quoted string -->`, with a
602
+ warning. lsf rendered nothing and said nothing.
603
+ - **`url_escape` encodes `#` and keeps `[` and `]`**, as Shopify does (Ruby's `URI` escape): a
604
+ `#` in a value cut the URL at a fragment.
605
+ - **The Section Rendering API reads its parameters as Shopify does**: `sections` wins over
606
+ `section_id` (lsf answered the HTML of `section_id`), an empty `?sections=` answers `{}` (lsf
607
+ answered the page), and its ids are taken as written, so `?sections=a,%20b` gives `" b": null`
608
+ where lsf trimmed it and rendered `b`. `lsf render` reads them the same way.
609
+ - **A disabled section asked for by `section_id` renders nothing**, `200` with an empty body, as
610
+ on Shopify; lsf rendered it. A disabled section in `sections` is an empty string.
611
+ - **A cart request whose `sections_url` does not start with `/` answers `400`**,
612
+ `{"errors":"sections_url must be a relative path"}`, as Shopify's Ajax Cart API documents; the
613
+ cart still changes. lsf rendered the sections against that page.
614
+ - **Shopify's own strings follow the theme's overrides of their keys**, as on Shopify: a
615
+ `shopify.*` key in a locale file (`shopify.collections.sorting.manual` in `locales/fr.json`)
616
+ now names the sort options of collections and of the search page, the values of the
617
+ availability filter, the titles of `link_to_add_tag` and `link_to_remove_tag` and the search
618
+ page's `page_title` on its route (still HTML-escaped: `Search: 2 results found for
619
+ &quot;widget&quot;`); lsf always printed them in English. Shopify publishes its
620
+ defaults in English only, so a key the theme does not override stays English
621
+ ([compatibility](docs/compatibility.md#known-differences)).
622
+ - **A blog's tag links have Shopify's words for articles** (`Narrow search to articles also
623
+ having tag Linen`), no longer those for products.
624
+
625
+ #### The checkout of an app
626
+
627
+ - **The theme's cart runs the cart transforms of the app**, as Shopify runs them on the cart:
628
+ with `--app`, a line an expand or an update changed has the title and price the transform gave
629
+ it in Liquid `cart` and `/cart.js`, which count it in the totals, `has_components: true`, and
630
+ its components in Liquid `item.item_components`. The cart showed the raw line, so a theme's
631
+ bundle display was tested on lines Shopify never shows. A merged line still shows as its lines.
632
+ - **`shopify.version` is the extension's API version** (`"2026-10"`), no longer `"lsf"`, in
633
+ checkout and customer account UI extensions, as Shopify's Version API gives it. A gate such as
634
+ `shopify.version >= '2026-04'` now compares versions, and a URL built from it names one.
635
+ - **The global `navigation` of a customer account extension is `shopify.navigation`**, as on
636
+ Shopify, whose examples call `navigation.navigate('extension://orders')`. It was the browser's
637
+ Navigation API of the extension's frame, so `navigation.navigate(…)` did nothing.
638
+ - **A customer account extension's session token has `iss`**, `https://<shop>.myshopify.com/checkouts`,
639
+ as Shopify's tokens of these extensions have it, before `dest`. A backend that reads `iss` to
640
+ tell them from the admin's tokens now gets it. A checkout extension's token is unchanged.
641
+ - **On the order status page, the image of a line is an absolute URL**, as on Shopify:
642
+ `shopify.lines` of a customer account extension gives `http://127.0.0.1:9292/cdn/shop/files/…`
643
+ as `merchandise.image.url`, no longer a path, so `new URL(url)` and a backend that fetches it
644
+ work locally too.
645
+ - **`/account/addresses` leads to the profile of the app's customer account**, as on Shopify with
646
+ new customer accounts, no longer to the order list: a theme's `routes.account_addresses_url`
647
+ lands on `/account/profile`, where the addresses are.
648
+ - **The customer account takes its language from `?locale=`**, as Shopify's, whose account has no
649
+ language in its path: `/account/profile?locale=fr` gives its extensions French
650
+ (`shopify.localization.language`, `shopify.i18n`), and the session keeps it for the next pages
651
+ until another `?locale=`. lsf ignored the parameter.
652
+ - **The Admin API's `customerAccountPages` lists the pages of the customer account**, as on
653
+ Shopify, where it answered an empty list: the native pages (`orders`, `profile`, `settings`)
654
+ and one `CustomerAccountAppExtensionPage` per `customer-account.page.render` extension of the
655
+ app, with its `name` as `title`. A script that looks up the `resourceId` of a
656
+ `CUSTOMER_ACCOUNT_PAGE` menu item finds it locally. `customerAccountPage(id:)` reads one.
657
+ - **The customer account's header has the links of the menu `customer-account-main-menu`**, as
658
+ on Shopify, when the store data has the menu: a link to the shop or to an extension's full page
659
+ added there is in the header. It always showed Orders and Profile, which stay the links of an
660
+ account without the menu.
661
+ - **`lsf app check` and `lsf app function run --scenario` price a scenario as its served
662
+ checkout does.** A buyer in a market with its own currency gets the cart in that currency and
663
+ the market's `rate` as `presentmentCurrencyRate`, where both ran the Functions in the shop's
664
+ currency at `1.0`. An `expect` on totals now reads the amounts the page shows. Expectations
665
+ written in the shop's currency for such a scenario need the market's amounts.
666
+ - **A Function reads a reference metafield as the GID Shopify stores**, whichever form the store
667
+ data writes it in: a `variant_reference` written `"WIDGET"` reads
668
+ `gid://shopify/ProductVariant/<id>`, a `list.product_reference` written `["widget"]` reads
669
+ `["gid://shopify/Product/<id>"]`, in a metafield and in a metaobject's `field(key:)`, and so
670
+ does a checkout UI extension's `appMetafields`. They read the short form as written, so a
671
+ Function that compared it with a line's `merchandise.id` matched nothing.
672
+ - **The order summary of the checkout hides the private properties of a line**, those whose name
673
+ starts with `_`, as Shopify hides them from the buyer at checkout. The Functions, the
674
+ extensions' `shopify.lines` and the order still have them.
675
+
676
+ - **`/agents.md`, `/llms.txt` and `/llms-full.txt` render the theme's templates**, where they
677
+ were the theme's 404 page. `/llms.txt` renders `templates/llms.txt.liquid`, else
678
+ `templates/agents.md.liquid`, and `/llms-full.txt` the same with `llms-full.txt.liquid`, as
679
+ Shopify looks them up. They render as plain text, at the bare domain only, with `request` and
680
+ the `agents` object (`store_name`, `store_url`, `ucp_discovery_url`, `mcp_endpoint_url`,
681
+ `ucp_versions`, `currency`, `sitemap_url`) and no other global: `{{ shop.name }}` is blank, as
682
+ on Shopify. A theme without these templates still gets a 404, which now says that Shopify
683
+ serves a file of its own there.
684
+
685
+ - **`<shopify-account>` works.** A page that has the element gets the component's script and
686
+ the `shopify-customer-account-data` block, as on Shopify, where lsf added neither and only the
687
+ fallback content showed. The script is lsf's: the avatar (the signed-out icon, its slot and
688
+ part, or the initial of the customer logged in) leads to the sign-in page or to `/account`;
689
+ the account sheet is not drawn.
690
+
691
+ - **The page of a metaobject entry is at the URL handle of its type**, which
692
+ `metaobject_definitions.<type>.url_handle` of the store data sets, as Shopify's definitions
693
+ set `urlHandle`: with `"info": {"url_handle": "infos"}`, the entry `about` is at
694
+ `/pages/infos/about`, and so are `metaobject.system.url`, `canonical_url`,
695
+ `shopify://metaobjects/info/about` in a `url` setting and the menus of the Admin API;
696
+ `/pages/info/about` is not found. A type without one is still at `/pages/<type>/<handle>`.
697
+
698
+ - **URL redirects.** `redirects` in the store data lists the store's redirects (`path`,
699
+ `target`), as Online Store > Navigation > URL redirects makes them. A path that finds nothing
700
+ answers a `301` to its target, as on Shopify, where lsf rendered the theme's 404: the path
701
+ matches whatever its case, the query is carried to the target, and a path of the store keeps
702
+ the language of the request (`/fr/old-tee.html` → `/fr/products/tee`). A path that leads to a
703
+ page is not redirected. `render` prints `status 301 · to <target>`. With `--app`, they are
704
+ the Admin API's `urlRedirects`, and what the backend creates, changes or deletes (with
705
+ `urlRedirectCreate`, `redirectNewHandle` or an import) applies to the storefront.
706
+
707
+ - **`/browsing_context_suggestions.json` and the Atom feed of a blog are served**, where they
708
+ were the theme's 404 page. The suggestions answer in the shape of Shopify's localization
709
+ discovery guide, under a locale prefix too: `detected_values` names the visitor's country in
710
+ the page's language, and `suggestions` is empty, since lsf has no geolocation.
711
+ `/blogs/<handle>.atom` is an Atom feed of the blog's articles, newest first.
712
+
713
+ - **Fixed bundles.** A variant's `components` (a variant and a quantity each) make it a fixed
714
+ bundle, as `productVariantRelationshipBulkUpdate` does: its stock is the fewest bundles its
715
+ components make, its cart line has `has_components: true` in `/cart.js`, and Liquid's
716
+ `item.item_components` lists its components with their quantity and a part of the line's
717
+ price, where the data could not model one. With `--app`, the Admin API seeds them as
718
+ `productVariantComponents` (`requiresComponents: true`), and the components the backend sets
719
+ reach the storefront.
720
+
721
+ #### The Admin API, for the backend of an app
722
+
723
+ - **An order placed with a cart transform bundle lists its components**, as on Shopify: a line
724
+ the transform expanded or merged becomes a line item for each component, each in a
725
+ `LineItemGroup` that names the bundle (`LineItem.lineItemGroup`, `sales_line_item_group_id`
726
+ in `orders/create`). The order had the parent line alone. The components share the bundle's
727
+ price and discounts in proportion to their prices. An app that read the bundle as one line of
728
+ the order reads its components, as it must on a store.
729
+ - **A connection costs what it costs on Shopify**: 2 plus what one of its nodes costs times
730
+ `floor(2 × ln(first))`, the logarithmic formula Shopify uses since 2024, no longer 2 plus
731
+ `first` times it. `products(first: 100) { nodes { id variants(first: 50) { nodes { id } } } }`
732
+ asks for 92 points, as on a store, where lsf asked for 5302 and refused it with
733
+ `MAX_COST_EXCEEDED`; `products(first: 20) { nodes { handle } }` asks for 7, no longer 22.
734
+ `actualQueryCost` applies the formula to the nodes returned, and `Shopify-GraphQL-Cost-Debug`
735
+ lists a connection's `definedCost` as `null` and what is below it for one node, as Shopify
736
+ does. Throttling tests now reach `THROTTLED` with the costs a store charges.
737
+ - **`products(query: "title:…")` matches the whole title**, ignoring case, as a store does, no
738
+ longer a word of it: `title:cotton` no longer finds "Organic Cotton T-Shirt", and `title:Tee`
739
+ finds the product titled "Tee" but not "Tee & Trousers Set". `title:"Organic Cotton T-Shirt"`,
740
+ `title:Organic*` and `title:*cotton*` (a `*` inside a prefix matches anything) find it, and a
741
+ word without a field (`cotton`) still does. An app that looked products up by a word of their
742
+ title finds nothing on a store: use the whole title, a prefix, or the default search.
743
+ - **`metafieldDefinitionCreate` creates a definition over metafields of another type**, as a
744
+ store does, where lsf answered `UNSTRUCTURED_ALREADY_EXISTS`. Each existing value that is
745
+ valid for the definition takes its type and joins it (`"12.50"` becomes a `number_decimal`);
746
+ the others stay as they were, with `definition: null` (`"12,50 EUR"` stays a
747
+ `single_line_text_field`), as Shopify's reference of the mutation describes.
748
+ - **`metafieldsSet` refuses a value larger than Shopify allows**, with a `TOO_LONG` user error
749
+ on its `value`, where lsf stored it: 64KB (65,536 bytes) for most types, 2KB for `url` and
750
+ `id`, 128KB for `json`, each item of a list as its type, 2MB in all, counted in bytes as
751
+ Shopify counts them. The `metafields` input of a product, a customer or an app discount is
752
+ checked the same way. An import that writes such values now fails locally as it does on a
753
+ store.
754
+ - **An OAuth grant with other scopes shows in `accessScopes` and sends `app/scopes_update`.**
755
+ An offline grant through `/admin/oauth/authorize` with another `scope` already narrowed every
756
+ token of the installation; `currentAppInstallation.accessScopes` kept listing the scopes of
757
+ `shopify.app.toml`, and no `app/scopes_update` was sent. Both now follow the grant, as
758
+ `POST /__lsf/admin/install` does, so an app that decides from them whether to send the
759
+ merchant through OAuth again sees what its token can do.
760
+ - **A GID of the wrong type is an `Invalid id` error.** `product(id:)` given an order's GID,
761
+ `productVariant(id:)` a product's, or `node(id:)` a type that does not exist answer the field
762
+ `null` with `{"message": "Invalid id: gid://shopify/Order/1", "extensions": {"code":
763
+ "RESOURCE_NOT_FOUND"}, "path": ["a"]}`, as a store does, where lsf answered a quiet `null`.
764
+ The other fields of the query are answered. A GID of the right type that names nothing is
765
+ still `null` without an error. Code that only reads `data` sees no change; code that fails on
766
+ any `errors` now fails locally as it does on a store.
767
+ - **`cartToOrderCopyable` is for order metafield definitions only**, as Shopify's reference
768
+ says: on another owner type it is `eligible: false`, and `metafieldDefinitionCreate` or
769
+ `metafieldDefinitionUpdate` that enables it answers an `INVALID_CAPABILITY` user error and
770
+ changes nothing, where lsf created the definition.
771
+ - **`shop.customerAccountsV2.customerAccountsVersion` is `NEW_CUSTOMER_ACCOUNTS`**, the
772
+ accounts lsf simulates, no longer a defaulted `CLASSIC`; `loginRequiredAtCheckout` and
773
+ `loginLinksVisibleOnStorefrontAndCheckout` follow the store data's customer account setting,
774
+ and `customer_account_settings/update` says `new_customer_accounts`. An app that branches on
775
+ the version now takes the branch of new accounts.
776
+ - **`POST /__lsf/webhooks/trigger` and `lsf app webhook trigger` send a cancelled order for
777
+ `orders/cancelled`**: the first cancelled order of the shop, else the first order with
778
+ `cancelled_at` set to now and `cancel_reason` `customer`, where they sent the first order,
779
+ not cancelled.
780
+ - **`productSet` with `metafields` deletes the product's metafields it does not name**, with a
781
+ definition or not, as Shopify's reference of the mutation says of its list fields, where lsf
782
+ kept them: `metafields: [note]` leaves the product with `note` alone. The metafield of the
783
+ identifier's `customId` and another app's reserved metafields stay; without `metafields`,
784
+ nothing is deleted, and a variant's `metafields` still delete none of its others.
785
+ **Breaking**: a backfill that sends `productSet` part of a product's metafields now deletes the
786
+ others locally, as it does on a store. Send them all, or write them with `metafieldsSet`.
787
+ - **`scriptTagCreate` and `scriptTagUpdate` answer a user error and change nothing**, on every
788
+ API version, as Shopify does since October 1, 2026, where lsf created the script tag and
789
+ `content_for_header` loaded it. `scriptTags` and `scriptTagDelete` keep working.
790
+ **Breaking**: an app that installs a script tag now fails locally as it does on every store.
791
+ Replace it with an app embed block of a theme app extension, or a web pixel.
792
+
793
+ #### The command line
794
+
795
+ - **`lsf serve` and `lsf app dev` stop on `SIGTERM` or Ctrl-C while they start too**, during
796
+ the builds of `--watch --build` or right after `ready:`: they stop their builds and exit with
797
+ `0`, as once they run. The signal killed them, leaving the builds running.
798
+ - **`lsf check` no longer reports a `{% content_for 'block' %}` type that differs from its file
799
+ only by case as missing**, since Shopify renders `blocks/text.liquid` for `type: 'TEXT'`.
800
+ It is a `block_type_case` warning, with the type as the file is named in its `hint`, and the
801
+ render finds the file on every file system, no longer only on those that ignore case.
802
+ - **`lsf check` warns about a theme file that is not UTF-8** (`not_utf8`, at the line of its
803
+ first such byte), where it said nothing and `lsf render` reported the file as missing.
804
+ Shopify takes the file; the warning does not count as a problem.
805
+ - **`lsf app init` no longer writes a value for each setting of a checkout UI extension**
806
+ (`false`, `0`, `""`): a new install on Shopify reads them undefined until the merchant sets
807
+ them, and the scenarios now test that. It says which extensions read no settings, and where
808
+ to give them (`checkout.extensions.<handle>.settings`). A file it wrote before is kept.
809
+ - **`app function run --save-input` says why it cannot pick a run** when one Function ran for
810
+ the same target once per owner (two discounts that use it): `--format json` has the input of
811
+ each, in `runs[i].input`. It asked to name the Function and its target, which the command
812
+ already did.
813
+ - **lsf says it does not support a Sass asset**, where it answered a bare `404`. The
814
+ `.scss.css` URL of `assets/theme.scss.liquid` (or `theme.scss`) answers `404` with the reason,
815
+ in the terminal and in `X-LSF-Reason`; `render` prints it as a warning; `check` warns about
816
+ each Sass file of `assets/` (`sass_asset`) instead of calling its URL a missing asset. lsf
817
+ does not compile Sass: Shopify's theme requirements forbid `.scss` and `.scss.liquid` files,
818
+ so write the stylesheet in CSS.
819
+ - **`lsf profile --points` counts the products of a collection a storefront fetches**, no
820
+ longer every product of the collection: the page of a `{% paginate %}` (4 with `by 4`), else
821
+ at most 50, and for a `for` loop over `collection.products` with a `limit` below 50 outside
822
+ `paginate`, that many, as Shopify's performance guide describes. A small paginated block on a
823
+ large collection is no longer the costliest row of a page, and a `limit: 8` costs 8 products.
824
+ - **`lsf check` warns about a contextual file that applies nowhere**: one without a `parent`, or
825
+ whose `parent` is not the file its name adapts (`contextual_parent`), or whose `context` is not
826
+ a market or `b2b` (`contextual_context`).
827
+
6
828
  ## 0.3.0-rc.5 - 2026-11-13
7
829
 
8
830
  Fifth release candidate of 0.3.0. The errors of `/cart/change.js` read as a store's.