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 +840 -0
- package/docs/admin-reference.md +57 -14
- package/docs/admin.md +16 -8
- package/docs/checkout.md +145 -18
- package/docs/cli.md +81 -10
- package/docs/compatibility.md +201 -26
- package/docs/control-api.md +1 -1
- package/docs/data-format.md +153 -29
- package/docs/data-reference.md +49 -18
- package/docs/performance.md +5 -0
- package/docs/storefront.md +17 -1
- package/docs/testing.md +9 -6
- package/package.json +7 -7
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 "widget"`, 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 & "Jerry" <b>x</b> l'é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 & 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&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 & 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
|
+
"widget"`); 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.
|