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

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