local-storefront 0.2.0 → 0.3.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,1299 @@
1
+ # Changelog
2
+
3
+ What changed in each version of `lsf`, for someone who uses it. The section of a version is
4
+ what its [GitHub release](https://github.com/0xtlt/local-storefront/releases) says.
5
+
6
+ ## 0.3.0-rc.1 - 2026-11-12
7
+
8
+ lsf now serves a local GraphQL Admin API (version 2026-10) and delivers an app's webhooks to its
9
+ backend: the backend of a Shopify app runs and is tested offline, against the store data, with
10
+ the same data every run. The embedded app opens in a local admin, and the theme renders the app's
11
+ theme app extensions. Some store data, extensions and tests written for 0.2.0 need a change: see
12
+ Breaking changes.
13
+
14
+ ### Breaking changes
15
+
16
+ What to change when you upgrade from 0.2.0. Each one is told in full below.
17
+
18
+ - **A Function runs only when the shop has an enabled owner of it, as on Shopify.** At the
19
+ shop's checkout and in a scenario without `extensions`, a payment or delivery customization, a
20
+ validation or a cart transform that nothing enables no longer runs: in 0.2.0 every Function
21
+ ran. Enable it in the store data (`"enabled": true` in `checkout.functions.<handle>`), let the
22
+ backend create its owner through the Admin API, or name it in the scenario's `extensions`
23
+ ([what runs](docs/checkout.md#owners-and-scenarios)). Check:
24
+ - **the tests that drive the checkout** (`PUT /__lsf/session`, then `/checkout`): the
25
+ Functions no longer run there, and nothing in the test says so. A test that checks their
26
+ effect fails; one that only places an order passes without them. Give the session
27
+ `"enabled": true` in `data.checkout.functions.<handle>`, or open a scenario that names them
28
+ ([a checkout in a test](docs/testing.md#a-checkout-in-a-test));
29
+ - **`lsf app check`**: a Function that now runs in no scenario is no longer tested. The check
30
+ says so (`warning <handle>: ran in no scenario, so nothing checks it`, `ranInNoScenario` in
31
+ JSON) and still exits with `0`;
32
+ - **the `all-extensions` scenario that `app init` wrote in 0.2.0**: it has no `extensions`.
33
+ List the app's extensions in it, or write it again with `lsf app init --force`, which
34
+ overwrites the other scenarios too.
35
+ - **`shopify.appMetafields` has only the metafields an extension declares, as on Shopify.** In
36
+ 0.2.0 it had every metafield of the cart, declared or not: an extension that read one locally
37
+ could read nothing on Shopify. Declare each one the extension reads in
38
+ `[[extensions.metafields]]` (`owner_type = "CART"` for the cart's), with the app's namespace
39
+ written `$app` or `$app:<name>`: Shopify does not support `app--<id>--<name>` there, so such a
40
+ declaration reads nothing, and the loader warns. [Checkout](docs/checkout.md).
41
+ - **The dates of the store data are ISO 8601 only.** `May 1, 2024`, `2024/05/01`,
42
+ `05/01/2024`, `2024-5-1`, RFC 2822, an offset after a space and a time alone are now
43
+ `invalid_date` errors, and `validate` exits with `1`. Write `2024-05-01`, or
44
+ `2024-05-01T10:00:00Z`. The `date` filter of a theme still reads them all.
45
+ - **The storefront opens in the shop's country**, not in the first country of the data:
46
+ `localization.country`, `Shopify.country` and the localization form of a visitor who chose
47
+ none can change, and so can a page a test compared with 0.2.0's. A test that needs another
48
+ country gives its session one (`PUT /__lsf/session` with `country`).
49
+ - **Some results of the checkout change, to match Shopify's:**
50
+ - before an address with a country, no shipping rate is selected: `cost.shipping` is 0,
51
+ `cost.total` and `cost.dueNow` leave it out, and the Functions read
52
+ `selectedDeliveryOption: null`;
53
+ - with no address, the checkout UI extensions read the shop's country and its primary market,
54
+ as the Functions do (they read no country, and the market `primary`);
55
+ - a code whose discount Function answers a candidate of 0 is applied at 0, where it was "not
56
+ applicable".
57
+ - **For contributors: the crates are renamed.** `cargo build -p lsf` is now
58
+ `cargo build -p lsf-cli`, and the renderer, `lsf-core` in 0.2.0, is `lsf-theme`.
59
+ `cargo install --path crates/cli` does not change. [Architecture](docs/architecture.md).
60
+ - **`validate` and `check` find more problems, and exit with `1` for them.** `validate` now
61
+ checks typed metafields against their type and resolves their references
62
+ (`invalid_metafield`, `unknown_metafield_type`, `unknown_reference`), and checks
63
+ `theme_settings` against the theme's `config/settings_schema.json` (`unknown_setting`,
64
+ `invalid_setting`). `check` reports a snippet, a section, a section group or a key of the `t`
65
+ filter the theme names and does not have. Data and themes that passed with 0.2.0 can fail:
66
+ each problem has its file, its place and a hint.
67
+ - **`"available": false` alone makes a variant sold out**: tracked, with a quantity of 0
68
+ (`inventory_management` is `"shopify"`), as on Shopify. In 0.2.0 it was untracked, and themes
69
+ said "Unavailable". A page or a product JSON compared with 0.2.0's can change.
70
+ - **`check` reports what Shopify refuses on upload, and exits with `1` for it.** A
71
+ `{% schema %}` that does not parse, a setting type Shopify does not have, a setting without
72
+ its `id` or `label`, two settings with one id, a `select` default that is not an option, a
73
+ `range` default that is not one of its steps (`invalid_schema`, `unknown_setting_type`,
74
+ `duplicate_setting_id`); a theme block that `blocks/` does not have (`missing_block`); a `t:`
75
+ key of a schema missing from the schema locale (`missing_schema_translation`). A section or a
76
+ block with such a schema also renders a Liquid error, so `render --strict` can fail on a page
77
+ that passed. Horizon and Dawn have none.
78
+ - **`check --app` exits with `1` for an app block that renders nothing**: a block the app does
79
+ not have, or one its `enabled_on` or `disabled_on` keeps out of the template. These were
80
+ warnings, and the check passed.
81
+
82
+ ### Added
83
+
84
+ #### Getting started
85
+
86
+ - **Four quickstarts**, one page each from the first command to a test in CI: a theme
87
+ ([quickstart-theme](docs/quickstart-theme.md)), Functions and checkout UI extensions
88
+ ([quickstart-functions](docs/quickstart-functions.md)), an Admin API backend
89
+ ([quickstart-admin-api](docs/quickstart-admin-api.md)) and a public app made from the React
90
+ Router template ([quickstart-app](docs/quickstart-app.md)). The app's Playwright config is the
91
+ same in every page of the docs. It runs the backend from a build, so the test passes on a cold
92
+ checkout too: Vite's dev server reloads the first page while it optimizes its dependencies.
93
+ - **The docs ship in the npm package.** `node_modules/local-storefront/` has the `llms.txt`,
94
+ `docs/` and `CHANGELOG.md` of the version you installed, for a tool or an agent to read
95
+ offline.
96
+
97
+ #### Themes and the storefront
98
+
99
+ - **Theme app extensions render in the theme.** With `serve --app`, the app blocks a JSON
100
+ template or a section group names (`shopify://apps/<app>/blocks/<block>/<id>`) render where the
101
+ theme renders blocks: `{% render block %}` for a `@app` block of `section.blocks`,
102
+ `{% content_for 'blocks' %}` (Horizon), and the `apps` section. App embed blocks render before
103
+ `</head>` and `</body>` when `config/settings_data.json` turns them on, or `app.embeds` of the
104
+ store data does; `app.metafields` there sets the app-data metafields. In the Liquid of an
105
+ extension, `render`, `asset_url` and `t` read its own snippets, assets and locales, and the
106
+ `javascript` and `stylesheet` of a schema load once per page. A block that renders nothing
107
+ says why, in the terminal and in `X-LSF-Reason`. The `app:` line and the home page list the
108
+ extensions and their blocks; `check --app` checks the app blocks the theme names; `app build`
109
+ checks the extensions as Shopify does, where it said "nothing was built"; live reload follows
110
+ their files. [Storefront](docs/storefront.md#theme-app-extensions).
111
+ - **`lsf render --customer`, `--cart` and `--session`** render a page as a visitor with that
112
+ session: `--customer jane@example.com` (or `default`, `none`), `--cart TEE-BLK-M=2`
113
+ (repeatable), or the JSON of `PUT /__lsf/session`. A name the data does not have exits with
114
+ `2`. [render](docs/cli.md#render).
115
+ - **Under `--live-reload`, the page shows its problems.** A small panel, closed with its button,
116
+ lists the store data errors, the Liquid errors of the page and why a page is the 404 page.
117
+ Without `--live-reload` (tests, `--static`) pages are as Shopify renders them.
118
+ - **`validate` checks typed metafields.** A value its type refuses (`"two"` for a
119
+ `number_integer`, a `weight` in `"kgs"`, a rating out of its scale) is an `invalid_metafield`,
120
+ a type Shopify does not have an `unknown_metafield_type`, and a reference to a product,
121
+ variant, collection, page, article or metaobject the data does not have an
122
+ `unknown_reference`, with the closest name. A `weight`, `dimension` or `volume` takes the units of
123
+ Shopify's docs (`kilograms`) and their short forms (`kg`).
124
+ [Metafields](docs/data-format.md#metafields-and-metaobjects).
125
+ - **`validate` checks `theme_settings`** against the theme's `config/settings_schema.json`: an id
126
+ it does not have (`unknown_setting`, "did you mean"), a value its setting does not take
127
+ (`invalid_setting`: a `select` option, a `range`, a `checkbox`). `PUT /__lsf/session` refuses
128
+ them too. Without a theme, a warning says they are not checked.
129
+ - **`check` finds what the theme names and does not have:** `{% render 'x' %}` and
130
+ `{% include %}` of a missing snippet (`missing_snippet`), `{% section %}` and the section
131
+ types of JSON templates and section groups (`missing_section`), `{% sections %}`
132
+ (`missing_section_group`), a literal key of the `t` filter missing from the default locale
133
+ (`missing_translation`). Each has its file, its line and the closest name, in text and in
134
+ `--format json`. Horizon and Dawn have none.
135
+ - **`check` checks the `{% schema %}` of sections and theme blocks**, as Shopify does on
136
+ upload, and the theme blocks that schemas, JSON templates, section groups and
137
+ `{% content_for 'block', type: '…' %}` name. Each problem has its line and, for a typo, the
138
+ name it probably is: `setting "align" is of type "selct"` gives `did you mean "select"?`.
139
+ A section or block whose schema Shopify refuses also counts as a Liquid error of the page that
140
+ renders it (`render --strict`, `x-lsf-liquid-errors`, the panel of `--live-reload`): its
141
+ settings had no default, and nothing said why. [check](docs/cli.md#check).
142
+ - **`check` warns about `asset_url` of a file `assets/` does not have** (`missing_asset`), with
143
+ the closest name.
144
+ - **`check`, `validate` and `render` take `--format github`**: each problem is a GitHub Actions
145
+ annotation, shown on its line of the pull request. `validate` gives the line of the value in
146
+ the file of the store data. [GitHub annotations](docs/cli.md#github-annotations).
147
+ - **`routes --all`** adds every route in the other languages of the store (`/fr/…`), a search
148
+ that finds a product, and the sections the theme fetches: product recommendations and
149
+ predictive search. The CI loop of the [theme quickstart](docs/quickstart-theme.md#5-run-it-in-ci)
150
+ renders them all, and reports every page that fails, not only the first.
151
+ - **`validate` warns about a variant with `"available": false` and stock**
152
+ (`unavailable_in_stock`): Shopify never has both. lsf keeps it unavailable with its stock,
153
+ which Horizon shows as "Unavailable", and the hint says which field to remove.
154
+
155
+ #### Checkout and Functions
156
+
157
+ - **Expected results in checkout scenarios: `lsf app check` fails when a Function decides
158
+ wrong,** not only when it fails to run. A scenario's `expect` says what its checkout must look
159
+ like once the Functions ran: the payment methods, the express buttons, the delivery options,
160
+ the discounts, the lines and the errors, each with `includes`, `excludes` and `equals` (an
161
+ entry by its handle or name, or by the fields it must have, such as
162
+ `{"handle": "standard", "cost": "0.00"}`), and exact `totals`.
163
+ `"express_methods": {"excludes": ["paypal"]}` with `"payment_methods": {"includes": ["paypal"]}`
164
+ replaces piping `app function run --format json` into `jq`. Each expectation that is not met
165
+ prints the scenario, the assertion, what was expected and what the checkout has, and makes the
166
+ check exit with `1`; `--format json` has them in `expectations`. The Functions run as the
167
+ order is placed (`"step": "interaction"`: the page before it). `validate` warns about a name in
168
+ `excludes` that the checkout does not offer, which would check nothing. With `--extension`, the
169
+ `expect` of a scenario that runs a Function left out is not checked, and the summary says so:
170
+ its checkout is not the one it describes. The scenario schema has `expect`, and two scenarios
171
+ of the playground use it.
172
+ [Expected results](docs/checkout.md#expected-results).
173
+ - **`lsf app check --build`** builds the extensions first, for a clone where `dist/` is not
174
+ committed. `app check` also warns about a Function that ran in no scenario, so that nothing
175
+ checks it (`ranInNoScenario` in JSON), and its summary counts it (`no Function failed, 1
176
+ Function ran in no scenario`); the exit code does not change. `app check --help` describes
177
+ the JSON it prints. A missing build says to run `lsf app build <handle>`.
178
+ - **`lsf app function run --input <file>`** runs one Function on a JSON input (`-` for stdin),
179
+ as `shopify app function run --input` does, without the store data. A fixture of the unit
180
+ tests of Shopify's templates (`{"payload": {"target", "input", "output"}}`) runs as it is,
181
+ and its `output` is checked. `--export <name>` picks the target of a Function that has several,
182
+ as Shopify's `--export` does, and `--save-input <file>` writes the input lsf built for a
183
+ scenario's run. [Checkout](docs/checkout.md#run-a-function-on-an-input).
184
+ - **`lsf app function run --input <folder>`** runs each `*.json` fixture of the folder, as
185
+ the unit tests of Shopify's templates do: a line each, a summary, and exit `1` when one fails.
186
+ A fixture whose output is not the run's prints each path that differs, with what it expects
187
+ and what the run answered, instead of the expected output on one line. In an app with one
188
+ Function, or with the fixture in a Function's folder, the handle can be left out.
189
+ - **`lsf app function run` builds a Function whose files changed after its module was built**,
190
+ as `shopify app function run` builds before it runs: it ran the old module, without a word.
191
+ Each build lsf makes records what it was built from, in the user's cache folder.
192
+ - **`lsf app init` writes a stub of the settings of each UI extension** that declares some
193
+ (`[[extensions.settings.fields]]`): a value of each field's type in `app-settings.json`, to
194
+ fill in, as it does for the variables of a Function.
195
+ - **`lsf app function run <handle>` says when the checkout of the store data would not run the
196
+ Function.** A named Function runs whatever its owner, as before; when its payment customization
197
+ (or other owner) is disabled in the store data, or there is none, the output says so first
198
+ (`Named: …`, `forced` in JSON), and that it reads the files, not a running lsf.
199
+ - **`lsf app function typegen`** writes a JavaScript Function's GraphQL types
200
+ (`generated/api.ts`), offline, as `shopify app function typegen` does: the `typegen` script of
201
+ Shopify's templates works.
202
+ - **`lsf app function schema`** writes a Function's `schema.graphql`, the GraphQL schema of its
203
+ API at its `api_version`, as `shopify app function schema` does, from the schemas lsf
204
+ carries, without the network: in the Function's folder, by handle, or with `--path`;
205
+ `--stdout` prints it. [The schema of a Function](docs/checkout.md#the-schema-of-a-function).
206
+ - **A Shopify CLI to lsf table** in [checkout](docs/checkout.md#coming-from-shopifys-cli): each
207
+ `shopify app` command, lsf's, and what lsf does not do.
208
+ - **The settings of a UI extension can be set.** `checkout.extensions.<handle>.settings` of
209
+ the store data or of a scenario are the values `shopify.settings` (`useSettings`) reads, in
210
+ the checkout and the customer account, as a merchant fills them in. `validate` and the home
211
+ page warn about a key the extension's `[extensions.settings]` does not declare, a value of the
212
+ wrong type, or one not among its `choices`. `shopify.settings.value` was always empty.
213
+ [Checkout](docs/checkout.md#set-up-the-checkout).
214
+ - **Express checkout buttons.** The checkout shows the payment methods of the
215
+ `ACCELERATED_CHECKOUT` placement as express buttons above the contact section (Shop Pay,
216
+ PayPal, Apple Pay, Google Pay… in their colors, then "OR"), once the payment customizations
217
+ ran: a Function that hides a method there (`paymentMethodHide` with `placements`) now has a
218
+ visible effect. A click says lsf does not simulate the wallet. The page's JSON has
219
+ `expressMethods`, and every method its `placements`; `lsf app function run` prints each method
220
+ with its placements. The `payment-methods` scenario of `app init` has PayPal in both
221
+ placements, as Shopify offers it with PayPal Express on.
222
+ - **Order metafields on the order status page of the customer account.** A customer account UI
223
+ extension declares them with `owner_type = "ORDER"`, which lsf refused as an unknown owner
224
+ type. `shopify.appMetafields` of the order status targets now has them as `order` entries
225
+ (`target: {type: 'order', id: 'gid://shopify/Order/…'}`), read from the order in the Admin
226
+ API: what the backend sets with `metafieldsSet`, on an order placed in the local checkout or
227
+ on one of the store data, and the cart metafields copied onto an order placed here. As on
228
+ Shopify since 2026-04, a cart metafield is copied when the order is placed if an ORDER
229
+ definition with its namespace and key has `cartToOrderCopyable` enabled, made with
230
+ `metafieldDefinitionCreate` or declared in `shopify.app.toml`
231
+ (`[order.metafields.app.gift_message]` with `capabilities.cart_to_order_copyable = true`,
232
+ which lsf now reads). The checkout and its thank-you page have no `order` entry, as on
233
+ Shopify: an `ORDER` declaration reads nothing there. [Checkout](docs/checkout.md),
234
+ [Admin API](docs/admin.md#what-the-storefront-and-the-checkout-see).
235
+
236
+ #### The Admin API, for the backend of an app
237
+
238
+ - **A local GraphQL Admin API, version 2026-10, with every mutation.** A server with `--app`
239
+ answers `POST /admin/api/2026-10/graphql.json` over the store data, with the ids the
240
+ storefront, the checkout and the Function inputs use. Queries follow Shopify's 2026-10 schema
241
+ (its introspection matches Shopify's), with its validation errors, query costs
242
+ (`extensions.cost`, and the cost of each field with `Shopify-GraphQL-Cost-Debug: 1`),
243
+ throttling (`THROTTLED`, `MAX_COST_EXCEEDED`), cursors, the `query:` search syntax, and
244
+ `ACCESS_DENIED` for a scope the token lacks. All 496 mutations of 2026-10 are simulated,
245
+ the deprecated ones included, with their user errors, side effects and webhooks:
246
+ - the catalog: products, variants, media, `productSet`, collections and their sources,
247
+ bundles, combined listings, inventory, transfers and shipments, locations, publications,
248
+ channels, product feeds, selling plans, gift cards;
249
+ - orders: payments and payment terms, fulfillments and fulfillment orders (split, merge,
250
+ reroute), fulfillment services, shipping labels, refunds, returns and exchanges, order
251
+ edits, draft orders, disputes, store credit, risk, tax;
252
+ - customers and B2B companies, segments (their query language is evaluated over the
253
+ customers), saved searches, merges, data erasure, consent, marketing, analytics and a
254
+ ShopifyQL subset;
255
+ - the Subscriptions API: contracts, drafts, billing cycles and attempts (test cards decide the
256
+ outcome), and customers' payment methods;
257
+ - markets, catalogs, price lists and contextual prices, quantity rules, locales, web presences;
258
+ - metafields and their definitions, metaobjects, pages, blogs, menus, redirects (in bulk and by
259
+ CSV import), files, translations, policies;
260
+ - themes and theme files (written over the theme directory in memory, never to disk), script
261
+ tags (loaded by `content_for_header`), pixels, storefront tokens, delegate tokens;
262
+ - the app: billing, discounts and Function settings, bulk operations, staged uploads,
263
+ `appUninstall`, scope revocation, Flow triggers, point of sale.
264
+
265
+ A failed mutation changes nothing. New records have 13-digit ids, as Shopify's do, the same
266
+ every run. Discount summaries read like the admin's (`$5.00 off entire order • Minimum
267
+ purchase of $50.00`), a completed draft order keeps its discounts, a deprecated field reads
268
+ what its replacement has (truncated, before discounts or before taxes where the schema says
269
+ so), `productSet` refuses an option value `productOptions` does not
270
+ declare, the `shop/update` and `app/uninstalled` payloads have the shop's `domain`, and an
271
+ image uploaded to a staged target has its `width` and `height`. A product created without
272
+ `write_publications`, or a `fileCreate` source lsf does not download, gets a note in
273
+ `extensions.lsf.notes`. `GET /admin/oauth/access_scopes.json` lists the token's scopes; the
274
+ other REST paths answer JSON that says lsf does not simulate the REST Admin API.
275
+ `crates/app/tests/coverage.rs` fails if a mutation of a version lsf serves is not simulated.
276
+ Guide: [Admin API](docs/admin.md).
277
+ - **GraphiQL for the Admin API** at `/__lsf/graphiql`: the shops, their tokens and the served
278
+ API versions filled in, the schema's docs, the cost debug header. It works offline. The start
279
+ lines and the app's home page link to it. A browser that opens the GraphQL endpoint is
280
+ pointed there.
281
+ - **API version 2027-01, the release candidate, on demand.** `--admin-versions 2026-10,2027-01`
282
+ serves it next to 2026-10, each at its own `/admin/api/<version>/graphql.json`, with its own
283
+ schema, introspection, access scopes, search filters and webhook topics:
284
+ - a field or a mutation of one version is refused in the other with Shopify's error;
285
+ - a webhook subscription is delivered in the version that made it (`X-Shopify-API-Version`);
286
+ - the records are shared, and 2027-01 reads what it removed the way Shopify says to replace it.
287
+
288
+ A version Shopify no longer supports (`2025-10`) is answered by 2026-10, as Shopify falls
289
+ forward, and `X-Shopify-API-Version` says so. Another answers `404` saying which versions are
290
+ served. `lsf app webhook trigger --api-version 2027-01` builds the payload in 2027-01 for
291
+ every subscription, and says so of a subscription of another version.
292
+ [API versions](docs/admin.md#api-versions).
293
+ - **The storefront and the checkout show what the backend changes.** A product it creates is on
294
+ the theme's pages at once, and a draft leaves them. A customer it creates can log in at the
295
+ checkout. A metafield it sets is read by Liquid and by the Functions, and an app discount it
296
+ creates runs its Function at the checkout. Open pages reload. An order placed in the local
297
+ checkout is an order of the Admin API, and its `orders/create` webhook reaches the backend.
298
+ A checkout scenario added or changed on disk keeps what the backend set up; only a change of
299
+ the store's own data seeds the shops again, and the server names the files
300
+ ([when the data changes on disk](docs/cli.md#when-the-data-changes-on-disk)).
301
+ - **OAuth, token exchange and session tokens**, answered as Shopify answers them:
302
+ - the authorization code grant, and token exchange from a session token (embedded apps);
303
+ - expiring offline tokens and their refresh, and client credentials;
304
+ - a launch URL that opens the backend as Shopify's admin opens an embedded app.
305
+
306
+ Tokens have the scopes of `shopify.app.toml`. They are sealed with the client id and the
307
+ client secret, and lsf takes back one of an earlier run, in whatever order the backend presents
308
+ them, so a backend's stored session works after lsf restarts. A reset, a shop deleted or an
309
+ uninstall stops that for the installation: no token of an earlier run is taken back after it,
310
+ presented before or not ([tokens](docs/admin.md#tokens)). With `client_id = ""`, as the template
311
+ ships it, lsf takes `SHOPIFY_API_KEY` of the environment or of the app's `.env`, and warns when
312
+ it has to use its own. The guide says what Shopify's libraries need to reach lsf.
313
+ - **Webhooks, signed as Shopify signs them.** The subscriptions of `shopify.app.toml` and those
314
+ the backend makes with `webhookSubscriptionCreate` are delivered to the backend:
315
+ - with Shopify's headers and HMAC;
316
+ - retried after 1, 5 and 15 s;
317
+ - filtered by `filter` and `include_fields`;
318
+ - refused by the backend (`401`, `403`): the delivery's `reason`, one line of the server and
319
+ `lsf app webhook trigger` name the secret lsf signed it with, as `client secret:` does.
320
+
321
+ Payloads follow Shopify's 2026-10 examples, for every topic Shopify publishes an example of
322
+ (224 of the 227); the other three are logged, never sent with a made-up body. The storefront's
323
+ cart and the local checkout send `carts/*` and `checkouts/*`, and a checkout left without an
324
+ order becomes an abandoned checkout the backend reads. `lsf app webhook trigger` sends one on
325
+ demand, compliance topics included, as `shopify app webhook trigger` does (`--format json`
326
+ for a tool). After an uninstall, nothing is delivered for the shop but `app/uninstalled`.
327
+ - **Shopify's Events of `[events]` in `shopify.app.toml`.** lsf delivers the `Product` Events
328
+ (`create`, `update`, `delete`) when a product or a variant changes through the Admin API, with
329
+ their triggers, their `query`, their `query_filter` and Shopify's `Shopify-*` headers. The
330
+ template's `webhooks.products.update.tsx` receives them. `GET /__lsf/webhooks?topic=Product`
331
+ lists them, and `lsf app webhook trigger` names them for a topic that has no webhook
332
+ subscription ([webhooks](docs/admin.md#webhooks)).
333
+ - **Webhooks stay on this machine.** lsf sends one to this machine (`localhost`,
334
+ `*.localhost`, `127.0.0.0/8`, `::1`), to `--app-url`, or to the `--address` you give, nowhere
335
+ else: a subscription that points at your production backend or a stale tunnel is logged
336
+ `not_sent`, with why, and `lsf app webhook trigger` sends nothing and exits with `2`. Give
337
+ `--app-url http://localhost:<port>`: the start lines say when it is missing. A name under
338
+ `localhost` (`app.localhost`) goes to the loopback whatever your system's resolver says.
339
+ - **The webhook log, for tests.** `GET /__lsf/webhooks` lists the deliveries, filtered by
340
+ `topic`, `state`, `shop` (`test-1` or `test-1.myshopify.com`), `resource` (the GID of a
341
+ record: the order a test placed) and `since` (a delivery's id). `?wait=1` waits for one that
342
+ matches every filter. Every answer has `last`, the newest id, to pass as `since` next time:
343
+ a test no longer gets another test's webhook. A wait that could only time out (a shop lsf
344
+ does not have, a topic no subscription covers) answers `422` at once.
345
+ [Control API](docs/control-api.md#webhook-deliveries).
346
+ - **The start lines of an app.** With `--app`, the Admin API's URL and token, where the client
347
+ secret came from, where the webhooks go and the app proxy are printed before `ready:`: a tool
348
+ that reads up to `ready:` has the token. Without the Admin API, none of them. An app without
349
+ an `extensions` directory is said to be a backend, in one line.
350
+ - **The app proxy.** A storefront request under the prefix of `[app_proxy]` goes to the backend
351
+ with Shopify's parameters and signature. A Liquid answer is rendered in the theme's layout.
352
+ [App proxy](docs/admin.md#app-proxy).
353
+ - **Control endpoints under `/__lsf/admin`:** reset the shop, make and delete extra shops for
354
+ isolated tests, install and uninstall the app, and make session tokens for a test to call the
355
+ backend's routes. [Control API](docs/control-api.md).
356
+ - **An uninstall does what Shopify does** (`POST /__lsf/admin/uninstall`, or `appUninstall`).
357
+ It revokes the app's tokens on the shop, cancels its subscription and delivers
358
+ `app/uninstalled`, then:
359
+ - **its Functions stop.** Every object that runs one of them goes, with its metafields:
360
+ payment and delivery customizations, validations, cart transforms, fulfillment constraint
361
+ rules and app discounts, those of the store data included (`removedFunctionOwners`). While
362
+ the app is uninstalled, the shop's checkout runs none of its Functions, even one a scenario
363
+ names in `extensions`. A reinstall finds none of them, as on Shopify: the app creates them
364
+ again;
365
+ - **its UI extensions no longer render**: the checkout, the thank-you page (of an order placed
366
+ before included) and the customer account load none, and an extension's full page answers
367
+ `404`. Each place says why, "the app is not installed on the shop", and so do the home page
368
+ (`"installed": false` in its JSON) and the terminal. Its script tags and web pixel are
369
+ deleted (`removedScriptTags`, `removedWebPixels`). A reinstall renders the UI extensions
370
+ again (lsf has no checkout editor); the app creates its script tags and pixel again. Theme
371
+ app blocks and embeds are files of the theme, and still render;
372
+ - **its app proxy no longer forwards**: `/apps/<subpath>` is the theme's `404` page, nothing
373
+ reaches the backend, and `X-LSF-Reason` and the terminal say why. A reinstall forwards it
374
+ again;
375
+ - **its own metafield and metaobject definitions go**, those of its reserved namespaces
376
+ (`$app`, `$app:<name>`) and types (`$app:<type>`), declared in `shopify.app.toml`, in the
377
+ store data or made through the Admin API (`removedMetafieldDefinitions`,
378
+ `removedMetaobjectDefinitions`). Their metafields keep their values and their metaobjects
379
+ are kept aside (`retainedMetaobjects`); a reinstall creates the definitions again under new
380
+ ids, and gives them back. Definitions of other namespaces and types are the merchant's and
381
+ stay.
382
+
383
+ [Uninstall](docs/admin.md#tokens).
384
+ - **The shop's clock and its outbox.** `POST /__lsf/admin/clock` moves a shop's time: payment
385
+ schedules come due, store credit expires, payouts are made, scheduled listings go live,
386
+ checkouts are abandoned, data erasures run, each with its webhooks. What Shopify would send
387
+ the outside world (emails, labels, Flow triggers, point-of-sale messages) is never sent: it is
388
+ in `GET /__lsf/admin/outbox`.
389
+ - **`--only theme,checkout,admin` runs some parts of lsf** (also `LSF_ONLY`), on `serve` and
390
+ `app dev`: the storefront of the theme, the checkout of the app (its Functions, UI
391
+ extensions, scenarios), its Admin API (OAuth, webhooks, the app proxy, lsf's admin, managed
392
+ pricing). `serve` runs the theme, all three with `--app`; `app dev` the checkout and the Admin
393
+ API. `serve --app ../my-app --only admin` is the Admin API alone, for a backend's tests, without
394
+ a theme folder. A part left out answers `404` on its routes, with an `X-LSF-Reason` that names
395
+ the `--only` serving it; a line before `ready:` says what runs (`serving: theme, checkout,
396
+ admin`), and `GET /__lsf/status` and `GET /__lsf/admin` list it (`parts`). An option of a part
397
+ left out (`--pricing-plans` without the admin part, `--variant` without the checkout) stops the
398
+ start when given on the command line; set by its variable (`LSF_APP_URL`), it is ignored and
399
+ said. `serve` without `--app` serves no Admin API: it costs memory a theme does not use.
400
+ [What runs](docs/cli.md#what-runs).
401
+ - **New options:**
402
+ - `--app-url`: where the backend listens.
403
+ - `--no-functions`: the app's UI extensions and Admin API, and none of its Functions, for a
404
+ backend's tests.
405
+ - `--client-secret`: else `SHOPIFY_API_SECRET`, else the app's `.env`.
406
+ - `--admin-throttle`.
407
+ - `--admin-versions`: the API versions served.
408
+ - `--admin-fetch-local`: sources from a URL (`themeCreate`, `themeFilesUpsert`,
409
+ `urlRedirectImportCreate`) may be on this machine. Off by default, as Shopify cannot reach
410
+ it.
411
+ - `--admin-source <url>=<file>`: a URL of your backend's own answered with a file, never
412
+ downloaded; `PUT /__lsf/admin/sources?url=` stubs one from a test.
413
+ - **Ids are unique across shops.** A record a test shop creates (`POST /__lsf/admin/shops`)
414
+ has an id no other shop gives: a product, an order, a customer, a metafield, a charge. A
415
+ backend that keeps every test shop in one database does not mix them up. The ids of a shop
416
+ are a number of its name alone, the same every run whatever other shops the run makes and in
417
+ whatever order; the store data's records keep their ids in every shop. A test shop's ids have
418
+ 14 digits, and its charges no longer count from 1:
419
+ `/__lsf/admin/charges/<n>` takes the number of the charge's id, as the `confirmationUrl` gives
420
+ it. The default shop does not change.
421
+ - **Webhooks:** a subscription made with `metafieldNamespaces` (or `metafields`) gets the
422
+ record's metafields of those namespaces in its payload, as `metafields`. A `filter` that reads
423
+ a field `includeFields` leaves out delivers nothing, as Shopify requires the field in
424
+ `includeFields`: the subscription's answer and each `not_sent` delivery name the field to add.
425
+ - **`Metaobject.referencedBy`** lists the metafields and metaobject fields that reference the
426
+ entry. A list lsf does not compute (`Order.agreements`, `Order.events`) is now in
427
+ `extensions.lsf.defaulted`.
428
+ - **More of Shopify's checks:** an input list over 250 items (`nodes(ids:)` with 251 ids) is
429
+ refused with `MAX_INPUT_SIZE_EXCEEDED`, except where Shopify documents another limit (25
430
+ metafields for `metafieldsSet`, 2048 variants for `productSet`); the arrays of a `JSON` value
431
+ are not counted; a `metaobject_reference` definition needs a
432
+ `metaobject_definition_id` the shop has; `bulkOperationRunMutation` refuses a mutation the
433
+ schema refuses, or one with more than one mutation field (`INVALID_MUTATION`).
434
+ - **`description`** keeps words whole: `it<em>s</em>` reads `its`; only block tags and `<br>`
435
+ separate words.
436
+ - **Scopes changed in `shopify.app.toml`** while lsf runs are said in the terminal, with how to
437
+ grant them (restart, or `POST /__lsf/admin/install`).
438
+ - **`examples/admin-backend`:** a backend written with Shopify's libraries
439
+ (`@shopify/shopify-app-react-router`, `@shopify/shopify-api`), with a script that runs it
440
+ against lsf end to end (`mise run admin:e2e`).
441
+
442
+ #### The embedded admin and billing
443
+
444
+ - **An admin to preview the embedded app in, with a local App Bridge.**
445
+ `http://127.0.0.1:9292/admin/apps/<handle>` (`/admin` too) shows the app in a frame, at a new
446
+ launch URL on every load, with the shop's name, the app's navigation and its title bar around
447
+ it; `serve` and `app dev` print its URL, and the home page links to it. Its navigation links to
448
+ the storefront and to the checkout the server runs (**Preview**: Online Store, Checkout), each
449
+ in a new tab, and lists no section of the admin lsf does not simulate (Orders, Products…).
450
+ lsf's App Bridge (`/__lsf/admin/app-bridge.js`) runs in the app's pages:
451
+ - `shopify.idToken()` and `fetch()` with the session token, as App Bridge v4 adds it;
452
+ - the title bar (`ui-title-bar`, `s-page`), the navigation (`s-app-nav`, `ui-nav-menu`), the
453
+ save bar (`ui-save-bar`, `data-save-bar` forms), toasts, the loading bar, `shopify.user()`;
454
+ - the resource picker, with the shop's products, variants and collections;
455
+ - modals (`ui-modal`, `s-app-window`, Polaris's `s-modal`), over the whole admin as on
456
+ Shopify, and `shopify:admin/…` links;
457
+ - `shopify.intents.invoke()` of `edit:shopify/Product` (the template's **Edit product**),
458
+ `…/ProductVariant` and `…/Collection`: a dialog shows what lsf has of it, and closing it
459
+ answers `{code: 'closed'}`. Another intent answers `{code: 'error'}` and says why.
460
+
461
+ What lsf does not simulate (the scanner, POS, Sidekick, …) rejects or warns and says so.
462
+ [Preview the embedded app](docs/admin.md#preview-the-embedded-app).
463
+ - **The embedded app opens in lsf's admin as the React Router template ships it.** The admin's
464
+ frame goes through a proxy on a port of its own. It lets lsf's admin frame every page, the
465
+ library's bounce page and `redirect(…, {target: "_top"})` page included, gives the pages lsf's
466
+ App Bridge and Polaris (a `Content-Security-Policy` that allowed Shopify's CDN allows lsf there
467
+ too), and says Chrome to the template's bot check, so that Playwright's
468
+ headless Chromium gets no `410 Gone`. React hydrates the pages without a "Prop `src` did not
469
+ match": lsf's App Bridge puts the CDN's URLs back in the scripts the proxy pointed at lsf. The
470
+ template's one edit is the fetch function, for the Admin API: lsf warns, on the home page and
471
+ in its admin, when `app/shopify.server.ts` has none or `app/routes/app.tsx` has no `headers`
472
+ export. The admin reads the files again at each load, so the warning goes once they are
473
+ fixed. A backend started without `LSF_URL` sends its calls to Shopify: when a page of it fails
474
+ and lsf has received nothing from it yet (a token exchange, OAuth, an Admin API call with a
475
+ token they issued: not a test's calls with the printed token), the admin and the terminal
476
+ say to check `LSF_URL`. A backend that talked to lsf is not blamed for later failures; one
477
+ the frame finds stopped is watched again.
478
+ `LSF_ADMIN_FRAME=direct` opens the backend
479
+ itself, and the admin says when the app runs Shopify's App Bridge instead
480
+ ([preview the embedded app](docs/admin.md#preview-the-embedded-app)).
481
+ - **Polaris without the internet.** lsf serves the paths of Shopify's CDN at `/__lsf/cdn/<path>`:
482
+ in lsf's frame, an app's pages load Polaris from there. `--polaris online`, the default,
483
+ redirects to the CDN; `--polaris offline` (or `PUT /__lsf/polaris` while lsf runs) serves lsf's
484
+ copy of Polaris 2.0.0 and 1.1.0, its icons and its fonts, pointed at lsf, so that the
485
+ components, icons and fonts render with no request to `cdn.shopify.com`. Offline, a page that
486
+ still loads Polaris from the CDN gets lsf's copy from lsf's App Bridge when the CDN's fails.
487
+ The app and lsf can share a host on two ports: lsf answers the CORS preflights.
488
+ [Polaris without the internet](docs/admin.md#polaris-without-the-internet).
489
+ - **Billing.** `appSubscriptionCreate` and `appPurchaseOneTimeCreate` answer a
490
+ `confirmationUrl` on the shop's admin, as Shopify's, which the library's exit-iframe page
491
+ accepts: `billing.require` and `billing.request` work in the loader of a page. lsf's admin
492
+ serves that path and shows a local approval page, and the frame's proxy answers it on lsf's
493
+ origin in a redirect and in `X-Shopify-API-Request-Failure-Reauthorize-Url`. An app with
494
+ `embedded = false` gets the same path on lsf's origin, which works as it is; for an embedded
495
+ app, the answer's `extensions.lsf.localConfirmationUrl` has it, for a test that opens the page
496
+ itself. Approving goes to the `returnUrl`, and the
497
+ charge is active. Subscriptions renew on the shop's clock: past `currentPeriodEnd`, the
498
+ period moves on by its interval, the usage balance starts again, and a limited discount has
499
+ an interval less. [Approve a charge](docs/admin.md#approve-a-charge).
500
+ - **Managed pricing (Shopify App Pricing).** Declare the app's plans in a file
501
+ (`--pricing-plans pricing.toml`) or through `PUT /__lsf/admin/pricing-plans`. lsf's admin shows
502
+ them at `/admin/charges/<app handle>/pricing_plans`, where a `shopify:admin/charges/…` link
503
+ and the template's `redirect(…, { target: "_top" })` land. The merchant picks a plan: a test
504
+ subscription with the plan's `planHandle` replaces the current one, `app_subscriptions/update`
505
+ is delivered, and the browser goes to the plan's welcome link with `charge_id` and
506
+ `plan_handle`. The Billing API then refuses to create charges, as Shopify does. With
507
+ `legacy = false` (Shopify App Pricing since April 2026), no change of the subscription sends a
508
+ webhook, `appSubscriptionCancel` from the app included. The page takes the app's handle only:
509
+ a client id there is a `404`, as on Shopify, unless `shopify.app.toml` has no handle.
510
+ `pricing.toml` is read again when it changes (unless `--static`), and `DELETE
511
+ /__lsf/admin/pricing-plans` reads it again; plans a `PUT` set stay until the `DELETE`.
512
+ [Managed pricing](docs/admin.md#managed-pricing).
513
+ - **A test ends the merchant's plan: `POST /__lsf/admin/pricing-plans/cancel`.** The shop's
514
+ subscription of managed pricing is `CANCELLED` at once, without the app's token, and
515
+ `app_subscriptions/update` is delivered, none with `legacy = false`: the case of an app that has
516
+ to notice by itself that the plan ended. Shopify's plan page has no button for it (the merchant
517
+ chooses another plan or uninstalls), so lsf's page has none either: the route stands for a plan
518
+ that ends while the app stays installed. [Managed pricing](docs/admin.md#managed-pricing).
519
+ - **A local Partner API: an app on Shopify App Pricing reads its plan.** Since April 2026 such an
520
+ app learns its plan from the Partner API's `activeSubscription`, not from a webhook. lsf serves
521
+ it at Shopify's path on its own origin, `POST /{partner_id}/api/2026-10/graphql.json` (also
522
+ 2026-07 and 2027-01), with Shopify's schema: the app changes only the origin of
523
+ `partners.shopify.com`. `activeSubscription` answers the shop's plan, its trial and its billing
524
+ cycle by the shop's clock; `events` and `app { events }` answer the history: installs,
525
+ uninstalls, plans chosen, changed and ended, and every change of a charge. Both `legacy` modes
526
+ feed it. The Partner ID and the `prtapi_lsf_` token are printed at start (`partner:`, with the
527
+ plans) and in `GET /__lsf/admin/partner`; they are the same every run. A wrong token is
528
+ Shopify's `401`. The rest of the Partner API answers `LSF_NOT_SIMULATED`.
529
+ [Read the plan from the Partner API](docs/admin.md#read-the-plan-from-the-partner-api).
530
+
531
+ ### Changed
532
+
533
+ #### Checkout and Functions
534
+
535
+ - **`lsf app check` fails on what it cannot check, with why.** An extension the checkout does
536
+ not offer (an `api_version` other than `2026-10` and `2027-01`, no module or bundle, and for
537
+ one `--extension`, a scenario or a discount names, no target the checkout has) is a failure,
538
+ where it passed (`every Function ran`) or was said to run in no scenario. A Function whose
539
+ files changed after its module was built is `stale` unless `--build`. What Shopify rejects
540
+ in a theme app extension fails it too. JSON: `notOffered`, `stale`, `themeExtensions`.
541
+ [app check](docs/cli.md#app-check).
542
+ - **`lsf validate` checks the theme app extensions of the app** as `lsf app build` does, and
543
+ exits with `1` on what Shopify rejects (a block without `{% schema %}`).
544
+ - **A fixture's `target` or `export` that is not the Function's is an error** of `app function
545
+ run --input`, not left aside.
546
+
547
+ - **A Function runs only through an enabled owner, as on Shopify.** At the shop's checkout (no
548
+ scenario, or a scenario without `extensions`), a payment or delivery customization, validation
549
+ or cart transform Function runs only when the shop has an enabled owner of it: the
550
+ `PaymentCustomization`, `DeliveryCustomization`, `Validation` or `CartTransform` the app
551
+ creates through the Admin API, or one the store data declares
552
+ (`checkout.functions.<handle>.enabled`, new). Disabling it (`paymentCustomizationUpdate`,
553
+ `…Activation`, `validationUpdate(enable:)`) or deleting it takes it out of the next checkout.
554
+ A scenario that names a Function in `extensions`, and `lsf app function run <handle>`, still
555
+ run it alone, without an owner; `app init`'s scenarios name theirs, `all-extensions` every
556
+ extension. The checkout's "Functions" dialog, the home page and `app function run` say which
557
+ Functions did not run and why (`idle` in JSON). Store data that relied on every Function
558
+ running adds `"enabled": true` to them. [What runs](docs/checkout.md#owners-and-scenarios).
559
+ - **No owner is created at install.** lsf no longer seeds an enabled payment customization,
560
+ delivery customization, validation and cart transform for every Function: a fresh shop has
561
+ none, so an app that checks whether it is set up sees that it is not. Only those the store data
562
+ declares (`enabled`) are there. Extension names written as a locale key (`name = "t:name"`)
563
+ are read from the extension's default locale (`locales/en.default.json`), for the Functions'
564
+ titles and wherever lsf shows a name.
565
+ - **`shopify.appMetafields` has the metafields an extension declares, of every resource, and no
566
+ others.** As on Shopify, it holds the metafields of `[[extensions.metafields]]` (and of
567
+ `[[extensions.targeting.metafields]]` at their target): those of the shop, of the products and
568
+ variants of the lines (and of the components of a bundle), of the customer logged in, of the
569
+ company and location they buy for and of the cart, each with its `target` (`shop`, `product`,
570
+ `variant`, …) and the GID of the resource, read from the store data and what the backend sets
571
+ through the Admin API. An `owner_type` keeps one resource. It held only the cart's, each one
572
+ whether declared or not: an extension that writes a cart metafield with
573
+ `applyMetafieldChange` and reads it back now declares it (`owner_type = "CART"`). Shopify's
574
+ checkout UI template in Preact reads `$app.requestedFreeGift` without declaring it: its
575
+ checkbox does not stay ticked until the declaration is added, as the playground's `ui-preact`
576
+ now does. The order status page of the customer account reads them too (it had none), with
577
+ the company location the order was placed for and the values typed as its `AppMetafield`
578
+ types them (`false`, `2`). A namespace written `app--<id>--<name>` in the toml reads nothing,
579
+ as on Shopify, and the loader warns with the `$app:<name>` to write. `shopify.shop.id` is the
580
+ id of the shop of the store data
581
+ ([#25](https://github.com/0xtlt/local-storefront/issues/25)).
582
+ - **`POST /checkouts/cn/<token>/complete` answers the order it placed:**
583
+ `{"redirect", "order": {"id", "number", "name"}}`, as the Admin API recorded it. Match a
584
+ webhook by `order.id`. The thank-you page keeps the session's number, which can differ
585
+ (`#1004` there, `#1005` in the Admin API when the shop has a `#1004` already).
586
+
587
+ #### Store data and the storefront
588
+
589
+ - **Store data:** products gain `status` (`active`, `draft`, `archived`) and `published`. Only
590
+ an active, published product is on the storefront. Entries of `checkout.discounts` gain an
591
+ `id`. [Data format](docs/data-format.md).
592
+ - **Two companies with the same name** are both kept, and reported (`duplicate_company`). The
593
+ second used to replace the first.
594
+ - **The dates of the store data are ISO 8601 only:** a day (`2024-05-01`), or a day and a time
595
+ (`2024-05-01T10:00:00`, or a space for the `T`) with `Z`, an offset or neither, for the shop's
596
+ `timezone`; `now` too. The other forms that Liquid's `date` filter reads were accepted as well,
597
+ and are now an `invalid_date` error: `May 1, 2024`, `2024/05/01`, `05/01/2024`, `2024-5-1`,
598
+ RFC 2822 (`Wed, 01 May 2024 10:00:00 +0000`), an offset after a space
599
+ (`2024-05-01 10:00:00 +0200`), a comma before the fraction of a second, and a time alone
600
+ (`10:00`, which took the day 2024-01-01). None of the examples or guides used one. The `date`
601
+ filter of a theme still reads them all.
602
+ - **The storefront opens in the shop's country, like the checkout.** A visitor who selected no
603
+ country is in the shop's (`shop.address.country_code`), the one of its primary market, or the
604
+ first country when the store does not sell to it: `localization.country`, `Shopify.country`
605
+ and the localization form say so. The storefront used to show the first country and the
606
+ checkout the shop's: the demo store showed the United States, then checked out in Canada.
607
+
608
+ #### The command line
609
+
610
+ - **`lsf serve --help` lists the theme's options first.** The options of an app are under
611
+ "App (with --app)", those of the Admin API and lsf's admin under their own heading. Its first
612
+ line, in `lsf --help` too, is about the theme; the app comes after.
613
+ - **`X-LSF-Reason` gives every reason of the page** when several app blocks render nothing
614
+ (`3 reasons: … | … | …`). It gave the first.
615
+ - **For contributors, the crates are split along what they need.** `lsf-core` is the store data
616
+ and what the storefront and the app share of it, `lsf-theme` the renderer (the old `lsf-core`),
617
+ `lsf-app` the app (`lsf-checkout` and `lsf-admin` in one crate: `config`, `checkout`, `admin`)
618
+ and `lsf-cli` the command line: `cargo build -p lsf-cli` builds `lsf`. `lsf-app` and
619
+ `lsf-core` build without Liquid or the renderer. Nothing else changes for someone who uses
620
+ `lsf`. [Architecture](docs/architecture.md).
621
+
622
+ ### Fixed
623
+
624
+ #### Themes and the storefront
625
+
626
+ - **A large store loads much faster.** The default product recommendations took a time cubic in
627
+ the number of products: 3,000 products took 5 to 6 s to load, and now take 0.3 s. This speeds
628
+ up startup and `--watch` too.
629
+ - **`validate` reports every problem in one run.** A data file with an error was left out whole,
630
+ so the problems of the rest of it showed only once that error was fixed. Its entries without
631
+ errors are now read and checked. A reference to an entity left out because of its errors says
632
+ so (`the product "a" is left out because products/a.json has errors`), rather than suggesting
633
+ another.
634
+ - **A JSON template that is not valid JSON, or names a section type the theme does not have, is
635
+ an error of the page.** It counts in `x-lsf-liquid-errors`, makes `render --strict` fail and is
636
+ in the log of `serve`, with the template, its line and the closest section type. The page
637
+ showed it with a 200 and 0 errors.
638
+ - **`--live-reload` no longer breaks a template that writes JSON.** Its script went at the end
639
+ of every response, `{% layout none %}` ones included (`?view=json-data`). It now goes only in
640
+ HTML pages with a `</body>` or an `</html>`.
641
+ - **`?section_id=` and `?sections=` say which section is missing.** `x-lsf-not-found` names the
642
+ ids the page does not have, the closest one, and the ids it has. The 404 had no reason.
643
+ - **`/de/` says the store has no German language**, when the theme has `locales/de.json` and
644
+ `localization.languages` does not list it, with what to add.
645
+ - **`'gift-card/card.svg' | shopify_asset_url` is served**, as a drawing of the size of
646
+ Shopify's: the gift card page of Dawn and Horizon showed a broken image.
647
+ - **`payment_button` speaks the language of the storefront.** "Buy it now" was English on
648
+ `/fr/` too; it is in the language of the page, for the languages of Shopify's checkout, and in
649
+ English for the others.
650
+ - **An unknown filter close to one of Shopify has a hint:** `{{ x | uppercas }}` gives
651
+ `hint: did you mean "upcase"?`, in the text and the JSON of `check`.
652
+ - **`lsf routes` no longer lists `/account`**, a page of lsf's, not of the theme. `GET
653
+ /__lsf/status` lists the same routes.
654
+ - **`render /account` answers as `serve` does**, with the customer of `--customer`: it was the
655
+ theme's 404. `/recommendations/products` and `/search/suggest` are answered as `serve` answers
656
+ them too, and a `section_id` or `sections` in the query renders those sections.
657
+ - **An app embed block the theme turns on says why it renders nothing without `--app`**, in the
658
+ terminal and in `X-LSF-Reason`, as an app block does.
659
+ - **`--theme` naming a folder that does not exist says so.** It said the folder "does not look
660
+ like a theme".
661
+ - **A control API body that is JSON is read as JSON** without `Content-Type: application/json`
662
+ (`fetch` sends a string as `text/plain`): `PUT /__lsf/session` used to read it as a form.
663
+ - **The docs no longer promise classic customer account pages.** `/account/login` and the
664
+ other account URLs lead to `/account`, as [storefront](docs/storefront.md) says;
665
+ [testing](docs/testing.md) said otherwise.
666
+
667
+ #### Checkout and Functions
668
+
669
+ - **No shipping in any total before an address.** Without an address with a country, no
670
+ shipping rate is selected: `cost.shipping` is 0, and `cost.total`, `cost.dueNow` and
671
+ `shopify.cost.totalAmount` leave it out, as the page does ("Enter shipping address").
672
+ `shopify.cost.totalShippingAmount` is undefined, and the Functions read
673
+ `selectedDeliveryOption: null` and a `cart.cost.totalAmount` without shipping, as on Shopify.
674
+ - **One country for a buyer with no address.** With no address and no selected country, the
675
+ Functions, the checkout UI extensions and the address form all read the shop's country
676
+ (`shop.address.country_code`), the one of its primary market. The extensions used to read no
677
+ country, and the market `primary`; they now read the market the Functions read.
678
+ - **A code whose discount Function answers a candidate of 0 is applied, at 0.** A candidate of
679
+ 0 % (or a fixed 0.00), which a Function answers to accept a code before the gift it stands for
680
+ reaches the cart, made the code "not applicable". As on Shopify, the code is now accepted and
681
+ its discount shows at 0 on the lines it targets: in the page, `discounts`, `discountCodes`,
682
+ the extensions' `discountAllocations` and the output of `lsf app function run`. An order or a
683
+ shipping candidate of 0 applies the same way; a shipping one gives way to a shipping discount
684
+ that takes something off the option the buyer selected. An automatic discount of 0 shows too,
685
+ at 0 (a Function that reads its percentage from settings that are not set)
686
+ ([#26](https://github.com/0xtlt/local-storefront/issues/26)).
687
+ - **The metafields of a discount or a Function's owner take Shopify's forms.** Besides
688
+ `"namespace.key"`, a list of `{namespace, key, type, value}`, as `metafieldsSet` takes it, is
689
+ read. The docs write them as Shopify's templates do, `"$app.function-configuration"` for
690
+ `metafield(namespace: "$app", key: "function-configuration")`, and `"$app:function-configuration"`
691
+ says to write a dot.
692
+ - **JavaScript, TypeScript and Preact builds no longer break after macOS empties the temporary
693
+ folder.** lsf's copy of the npm packages lived in `$TMPDIR`; once macOS's cleaner deleted its
694
+ files and kept its folders, every build failed with `cannot write lsf's npm packages` until
695
+ the folder was deleted by hand. The packages now live in the user's cache folder
696
+ (`~/Library/Caches/lsf/` on macOS, `~/.cache/lsf/` on Linux). A folder with a file missing or
697
+ changed is written again, safely when several lsf run at once.
698
+ - **A change to a Rust Function's query builds it again.** The `typegen` macro reads the
699
+ `.graphql` files without telling cargo: after a change to one of them alone, `--watch` said
700
+ `✓ delivery-rules built` while cargo compiled nothing, and the old module ran on the new
701
+ query's input. lsf now touches `src/main.rs` (its time only) before the build when a
702
+ `.graphql` file is newer than the module, and the watch says when a module came out the same
703
+ (a compiler cache that does not see the change).
704
+ - **`--watch` no longer rebuilds the Functions when the app's web front end builds.** In the
705
+ app's own folder, `--watch` now follows only what the builds of the Functions read: the
706
+ workspace's manifests and lock files (`Cargo.toml`, `Cargo.lock`, `package.json`, …) and the
707
+ folders that hold a crate or a package (`crates/common/Cargo.toml`). A project whose web build
708
+ writes `dist/` at its root on every save of `src/` built every Function and reloaded the pages
709
+ each time. `dist/`, `build/`, `target/`, `node_modules/`, dot folders (`.git/`, `.shopify/`)
710
+ and the app's own sources are left alone.
711
+ - **`--build` then no "no Wasm module".** `app function run`, `app dev` and `serve` printed
712
+ the diagnostics of the app as it was before `--build` built it.
713
+ - **An endless loop in a Function stops in a moment.** It ran for 10 s (some 35 billion
714
+ instructions) before it was stopped, and three of them took the 20 s of the home page's check.
715
+ A run now stops at 110 million instructions, ten times Shopify's limit:
716
+ `exceeded Shopify's limit of 11,000,000 instructions: stopped at 110,000,000 (an endless
717
+ loop?)`. A run under that keeps its exact count.
718
+ - **`app check` says what a trapped Function threw:** `the Function trapped: Error: boom (in
719
+ b2b)`, the last message it logged. Each JSON failure has the `logs` of the first scenario it
720
+ failed in. `lsf app function run`, the checkout's Functions dialog and their JSON have the
721
+ message too, where they said `the Function trapped` alone.
722
+ - **`lsf app check` prints each way a Function fails.** A Function that threw in one scenario
723
+ and answered a wrong output in another was printed with the first error for both, and the
724
+ JSON had the logs of the first. Each error is now a failure of its own, with its scenarios and
725
+ its logs, in the text, in `--format json` and on the home page.
726
+ - **`lsf app check` prints a scenario that does not validate once**, its problems indented under
727
+ it, and no longer says `no scenario to check` when the only scenarios are invalid.
728
+ `app function run --scenario` prints them once too.
729
+ - **An extension that does not build exits with `1`**, as the exit codes say of something to
730
+ fix (`app build`, `app function build`, `--build`). It exited with `2`, "could not run".
731
+ - **The home page has no warnings right after `lsf app init`.** It warned, for each Function
732
+ that is not a discount, that the shop has no customization that runs it, while the scenarios
733
+ `app init` wrote run each of them. It now warns only about a Function no scenario runs.
734
+ - **`app init`'s `all-extensions` scenario runs the discount Functions.** It entered the code
735
+ `WELCOME`, which no discount had, and named the discount Functions, which then never ran. It
736
+ now has a discount of each, with the code `SAVE10` entered, as `discount-code` does.
737
+ - **`app init`'s `discount-code` scenario** says that one Function "applies as a code
738
+ discount".
739
+ - **`lsf app init` counts customer account UI extensions apart**: `for 5 Functions, 1 checkout
740
+ UI extension and 1 customer account UI extension`, not `2 checkout UI extensions`.
741
+ - **`lsf app function run` prints no discount as `discounts 0.00`**, not `-0.00`.
742
+ - **`lsf app function run` says why shipping is 0 while a rate is listed:** no rate is
743
+ selected before a shipping address, as on Shopify, and the line gives the `--checkout` that
744
+ adds one.
745
+ - **`--save-input tests/fixtures/x.json` creates `tests/fixtures/`**, as the quickstart runs it:
746
+ it stopped with "No such file or directory".
747
+ - **`lsf app build` exits with `1` on an error of a theme app extension**, as on the other
748
+ errors to fix, not `2`.
749
+ - **The home page counts only the scenarios with problems.** Settings of a UI extension that
750
+ do not fit, in one scenario, are that scenario's warning: every scenario that ran the
751
+ extension was said to have problems (`11 scenarios · 11 with problems`).
752
+ - **A UI extension whose build fails under `--watch` says the previous bundle runs**, not "No
753
+ module of it built yet."
754
+ - **A JSX build that misses `react/jsx-runtime` says what to add:** a Preact extension without
755
+ its `tsconfig.json` needs `"jsxImportSource": "preact"`.
756
+
757
+ #### The command line
758
+
759
+ - **The `next:` line of `lsf app init` drops `--app` in the app's folder**, where `app dev`
760
+ finds the app.
761
+ - **The hints say `npx local-storefront` when it was installed with npm**, where `lsf` is not
762
+ on the PATH: ``build it with `npx local-storefront app build x` ``, the `next:` lines, the home
763
+ page. The launcher sets `LSF_COMMAND`; the binary of a release says `lsf`.
764
+ - **`lsf docs | head` ends quietly.** A command that only prints (`docs`, `schema`, `render`,
765
+ `routes`, `validate`, `check`, `fmt`, `profile`) stops like `cat` when its reader leaves,
766
+ instead of panicking on the closed pipe.
767
+ - **One is in the singular, everywhere lsf counts:** `1 Liquid error · 0 warnings` (`render`),
768
+ `wrote 1 file, kept 15 existing ones` (`init`, `app init`), `1 file formatted` (`fmt`),
769
+ `validate`, `check`, the banner, the store data's errors and the status page. No more `(s)`.
770
+
771
+ ## 0.2.0 - 2026-10-30
772
+
773
+ lsf now runs the checkout and the customer account of a Shopify app: its Functions and its UI
774
+ extensions, on your machine. Classic customer accounts are no longer simulated: see Changed.
775
+
776
+ ### Added
777
+
778
+ - **A local checkout for Shopify apps.** `lsf app dev --app <dir>` serves the checkout of an
779
+ app without a theme, and `lsf serve --app <dir>` leads the `/checkout` of a theme to it. It
780
+ runs the Shopify Functions of the app (cart transform, discounts, delivery and payment
781
+ customization, validation) from their built Wasm modules, and shows its checkout UI
782
+ extensions in the page, in the standard one-page look or in the Shop app's (`--variant shop`).
783
+ `--extension <handle>` (repeatable) picks the extensions to use, `--build` runs the build
784
+ command of each Function first. A badge in the corner of the page opens a dialog with the
785
+ input and output of each Function run side by side. API versions 2026-10 and 2027-01 are
786
+ supported. Guide: [checkout](docs/checkout.md).
787
+ - **Customer account UI extensions, and every checkout target.** With an app, `/account` is the
788
+ customer account of Shopify's new customer accounts: the orders of the customer logged in (from
789
+ the store data, and the last order placed in the local checkout), the status of an order (its
790
+ shipments, what is not shipped, its returns), the profile (addresses, and the company and
791
+ locations of a B2B customer), and the full pages of extensions. The 24 targets of customer
792
+ account UI extensions render there, with the `shopify` global they read (the account logged in,
793
+ the order, navigation, toasts) and the components only customer accounts have (`s-page`,
794
+ `s-customer-account-action`, `s-menu`, …); an order action opens its dialog. The checkout gains
795
+ the 14 targets it lacked: pickup locations and points, the targets of payment apps (with the
796
+ form dialog), the gift card form, bundle lines, the chat, and the address autocomplete, which
797
+ suggests addresses as the buyer types. An order of the store data can list its `returns`. See
798
+ [customer account](docs/checkout.md#customer-account).
799
+ - **The commands of a Shopify app are under `lsf app`**, named as Shopify's CLI names them:
800
+ `app dev` serves the app alone (its checkout and its customer account), `app init` writes
801
+ checkout scenarios, `app build` builds its extensions, `app check` runs the scenarios for CI,
802
+ `app function run` and `app function build` its Functions. [CLI reference](docs/cli.md#app).
803
+ - **Testing an app's checkout in CI.** `lsf app check` runs the Functions of every scenario
804
+ and exits with 1 when one fails or does not validate, or when there is no scenario. `lsf validate`
805
+ checks the app of the folder too (with the demo store when the app has no store data), and its
806
+ JSON counts the app's errors with the data's. The checkout and the customer account set `data-lsf-ready` on the document once nothing is
807
+ on its way and every extension rendered (`aria-busy` meanwhile). `DELETE /__lsf/sessions` resets
808
+ every session. The orders of a session are numbered after the last one of the store data, one
809
+ after the other whatever the other sessions do, and placed at its `now`: a test reads the same
810
+ number and date every time. [Testing guide](docs/testing.md#a-checkout-in-a-test).
811
+ - **Commands of an app need no `--app` in its folder** (`lsf app dev`, `lsf app init`,
812
+ `lsf app function run`, `lsf app check`), and read its `shopify-local/` from anywhere.
813
+ `lsf app init` gives every Function the settings its input query reads
814
+ (`[extensions.input.variables]`): those of the app's own store data, or empty values of the
815
+ right type in `app-settings.json`, so that the scenarios it writes pass `app check`; a
816
+ variable without a value says where to give it. The frame of an extension is in the language
817
+ it renders in (`lang`). `lsf app function run` says why a
818
+ Function asked for did not run (no discount names it, an empty cart, an error of the app) and
819
+ exits with 1; a handle or a scenario close to an existing one is suggested. Its `--session`
820
+ takes `data` as `PUT /__lsf/session` does: a discount or settings for one run.
821
+ - **Watch mode for the checkout of an app.** `lsf app dev --app <dir> --watch` (and `serve
822
+ --app <dir> --watch`) builds a Function again when its files (or the files the Functions
823
+ share, such as a Cargo workspace's `Cargo.toml`) change, and reloads the open checkout,
824
+ thank-you and home pages once the builds are over, as `shopify app dev` does. At start it
825
+ builds only the Functions that have no module or changed since the last watch (all with
826
+ `--build`). The checkout runs a copy of the last module that built, never what a build is
827
+ writing: a build that fails keeps it and shows its first error on the page and on the home
828
+ page. A checkout UI extension's bundle, `shopify.app.toml` and the scenarios reload the pages
829
+ too. See [watch mode](docs/checkout.md#watch-mode).
830
+ - **Log in as a customer at the checkout.** "Log in" in the Contact section of the local
831
+ checkout opens a menu of the customers of the store data. Picking one logs the checkout (and
832
+ the session) in as them: the Contact section shows the account, Delivery offers their saved
833
+ addresses with the default one chosen, a B2B buyer picks the location they buy for, and the
834
+ Functions and extensions read the new buyer. `/checkout?customer=<email>` opens the checkout
835
+ logged in, and the home page lists the customers. The demo store has a profile of each kind
836
+ (new, returning, B2B, abroad). See [log in as a customer](docs/checkout.md#log-in-as-a-customer).
837
+ - **The checkout looks like Shopify's.** The standard look of the local checkout now follows
838
+ Shopify's one-page checkout: its header, the two columns with the grey order summary, floating
839
+ labels, grouped payment and shipping choices, the order summary with its quantity badges, and
840
+ a mobile layout with a folded summary. The checkout UI extensions use the same design tokens
841
+ (`tokens.css`), so they look native in it. See [the look of the page](docs/checkout.md#the-look-of-the-page).
842
+ - **`lsf app function run [handle]... --app <dir>`** runs the Functions once, without a server, and
843
+ prints each input, output and log with the checkout that results. `--session` and
844
+ `--checkout` set the cart and what the buyer entered, `--format json` is for tools. It exits
845
+ with `1` when a Function fails to run.
846
+ - **A `checkout` section in the store data** sets the shipping rates, the payment methods, the
847
+ discounts that use the Functions of the app, the settings of its other Functions and a tax
848
+ rate. Without it: one "Standard" rate at 5.00, three payment methods and no tax.
849
+ - **The checkout in the control API.** `/checkouts/cn/<token>` serves the page, `.json` its
850
+ state, `/update` takes operations and `/complete` places the order, which `/thank-you` shows.
851
+ `GET /__lsf/checkout?session=<id>` reads the state of a session for tests. Without `--app`,
852
+ `/checkout` is the summary of the cart, as before.
853
+ - **The Functions of the checkout can read the fields they could not.** An input query that asks
854
+ for `Cart.deliverableLines`, the discount applications and allocations of the cart, its lines
855
+ and its delivery groups (each Function sees the discounts the steps before it applied),
856
+ `PurchasingCompany.contact`, the dates, locale and totals of companies and their locations,
857
+ `MailingAddress.market`, `PaymentCustomizationPaymentMethod.placements` or `Shop.metaobject`
858
+ now runs instead of failing with the field's path. The store data sets them: `created_at`,
859
+ `updated_at`, `locale`, `orders_count` and `total_spent` on companies and locations, a
860
+ `company_contact` on a customer, `localization.markets`, `placements` on a payment method;
861
+ everything has a default. Metaobjects are the ones of the data.
862
+ - **Network access for Functions.** The `.fetch` targets of the lines discounts, delivery
863
+ discounts and cart validation Functions run: the checkout sends the HTTP request they ask for
864
+ and gives the response to the `.run` target as `fetchResult`, with Shopify's limits (GET and
865
+ POST, HTTPS, a timeout of 100 to 2000 ms, 100 KB, no redirect; `http` is accepted for
866
+ `localhost`). The request and the response show in the Functions dialog and in
867
+ `lsf app function run`.
868
+ - **A deferred payment in the checkout.** A line sold with a selling plan that takes a deposit
869
+ (a pre-order, try before you buy: `checkout_charge` in the store data) is no longer charged in
870
+ full. The order summary shows the total, what is **due today** and what is **due later**; the
871
+ state (`.json`) has `cost.dueNow`, `cost.dueLater` and `cost.checkoutChargeAmount` (the
872
+ `cart.checkout_charge_amount` of the storefront cart) and `dueLater` on the lines; the order
873
+ records `totalReceived` and `totalOutstanding`, and the thank-you page shows the remaining
874
+ balance. `lsf app function run` prints it. Shipping is due today and tax follows the amount it is
875
+ on. The `shopify` global and the Function input do not change: Shopify exposes no deferred
876
+ amount there.
877
+ - **A home page for the checkout.** `GET /` of `lsf app dev` (and `/__lsf/checkouts`, which
878
+ `lsf serve --app` prints when it starts, as the theme owns `/`) lists every Function of the
879
+ app (API, targets, build status, network access with a `.fetch` target, what is wrong with
880
+ it) and every checkout UI extension (API version, capabilities, targets, and the place each
881
+ target has on the page, or that it has none), and a card for every checkout scenario, with a
882
+ link that opens a checkout for it. `?format=json` answers what it says.
883
+ - **Checkout scenarios.** A file of `checkouts/` in the store data (`checkouts/<name>.json`) is a
884
+ named checkout setup, on top of the store data: the cart (lines, quantities, selling plans,
885
+ note, attributes), the buyer (a guest, a customer, a company location), the country and
886
+ language, what the buyer entered (email, address, discount codes, delivery, payment),
887
+ overrides of the `checkout` section, the look and the extensions that run.
888
+ `/checkout?scenario=<name>` opens a fresh checkout for it, in a new session. Scenarios are
889
+ validated (`validate`, with the codes of the data guide), reloaded when they change, have a
890
+ JSON Schema (`schema checkout-scenario`, and `schema/` written by `init`), and one with errors
891
+ is listed with them, not hidden.
892
+ - **`lsf app init [--app <dir>]`** writes the scenarios: for the Functions and checkout UI
893
+ extensions of the app (a cart a cart transform can merge or expand, a cart and a code for a
894
+ discount, several shipping rates or payment methods, a cart a validation refuses, one with
895
+ everything for the extensions), and always a guest, a customer, a B2B buyer, another country
896
+ and language, a subscription, a pre-order with a deposit, the Shop look and a large cart. It
897
+ creates the data folder with the demo store if there is none, uses the products of an existing
898
+ one, and never overwrites without `--force`.
899
+ - **`/checkout?targets=all`** opens a checkout where every extension renders at every target it
900
+ declares: the steps of a cart that ships are always there, the thank-you page is shown under the
901
+ checkout, and a panel lists each target as shown, without a place on the page, or waiting for
902
+ something the cart lacks.
903
+ - **`lsf app function run --scenario <name>`** runs the Functions for a scenario.
904
+ - **`lsf app function build`** builds the Functions of an app. A JavaScript or TypeScript Function
905
+ without a build command, as Shopify's CLI generates it, is built by lsf as
906
+ `shopify app function build` builds it, without Node: its types (`generated/api.ts`, as
907
+ graphql-codegen writes them), `dist/function.js` (bundled as esbuild does, enums inlined and
908
+ unused code left out), then its module, the bytes Javy writes. `--build` and `--watch` build
909
+ it too.
910
+ - **Rust Functions run as `cargo build` writes them**: after a Function's build command
911
+ (`--build`, `--watch`, `lsf app function build`), a module that calls the Function Wasm API by name
912
+ is linked to it with Shopify's `shopify-function-trampoline` (2.0.1, as a library), as
913
+ `shopify app build` does, to the byte.
914
+ - **`lsf app build`** builds the extensions of an app as `shopify app build` does, without
915
+ Node: its Functions, and its checkout UI extensions whose targets name their sources, bundled
916
+ (JSX with the `jsxImportSource` of their `tsconfig.json`, minified) into `dist/<handle>.js`.
917
+ `--build` and `--watch` build those too. Without a `node_modules`, `preact`,
918
+ `@preact/signals`, `@shopify/ui-extensions` and `@shopify/shopify_function` come from lsf's
919
+ own copy.
920
+
921
+ ### Changed
922
+
923
+ - **Customer accounts are Shopify's new ones only.** Classic customer accounts are no longer
924
+ simulated: the theme's `templates/customers` are never rendered, every `/account/…` page and
925
+ account form (login, register, password reset, addresses) leads to `/account`, and customers
926
+ have no password. Data that still sets `shop.customer_accounts` or a customer's `password` is
927
+ read as before: those fields are ignored, with a warning. `serve` and `check` say that the
928
+ theme's `templates/customers` are not rendered (a warning of `check`, which does not fail). `/__lsf/status` no longer has
929
+ `customer_accounts`.
930
+ - **A field the format does not have no longer drops its file.** It is still an error, and is
931
+ left out: the rest of the file is read, and one typo gives one error. `data.checkout` of
932
+ `PUT /__lsf/session` replaces the store data's field by field, as a scenario's does, and keeps
933
+ the settings of the Functions it does not name.
934
+ - **The binaries are larger: they run the checkout.** lsf runs Functions with only what it uses
935
+ of wasmtime (the compiler for the machine it runs on) and Shopify's providers compressed
936
+ (1.6 MB instead of 9.7 MB), and has one copy of each Wasm library; the release binaries leave
937
+ out their symbol table and are optimized as a whole. `lsf` for macOS on Apple silicon is 40 MB
938
+ (18 MB to download), up from 33 MB (12 MB) in 0.1.18. The storefront renders as fast as
939
+ before.
940
+
941
+ ## 0.1.18 - 2026-10-21
942
+
943
+ ### Changed
944
+
945
+ - **Security update of the dependencies.** 33 crates are updated, among them `brotli` 9,
946
+ `jsonschema` 0.59 and `base64` 0.23. Nothing changes in what `lsf` does.
947
+ - **The release binaries are built with Rust 1.99.1**, up from 1.98.1.
948
+ - **The npm packages are built and published with Node 24.22.1.** The release and npm workflows
949
+ pin it, and the development toolchain pins Node 26.12.1.
950
+
951
+ ## 0.1.17 - 2026-10-20
952
+
953
+ ### Added
954
+
955
+ - **The pages of metaobjects are served.** With `templates/metaobject/<type>.json` (or
956
+ `.liquid`) in the theme, `/pages/<type>/<handle>` renders the entry, with the global
957
+ `metaobject`, `?view=` and a per-entry `template_suffix` in the data. `shopify://metaobjects/`
958
+ links lead there, and `metaobject.system.url` is nil for a type without a template, as on a
959
+ store whose definition has no web pages.
960
+ - **A 404 page says why.** The server answers it with an `x-lsf-not-found` header (no route,
961
+ unknown resource, no template) and logs the reason, so a test can fail on it.
962
+ - **`shopify_asset_url` files are served.** `vendor/qrcode.js` is the MIT qrcode.js library that
963
+ Shopify serves, so a gift card draws its QR code. The other documented files are small local
964
+ stand-ins: `shopify_common.js` formats money, the others only warn in the console.
965
+
966
+ ### Fixed
967
+
968
+ - **`metaobject` and `metaobject_list` settings are metaobjects**, not the string of the
969
+ template: a handle, `type.handle`, `type/handle` or a `gid://shopify/Metaobject/` id.
970
+ - **The search follows `sort_by`**: `relevance`, `price-ascending` and `price-descending`, with
971
+ the other results after the products under a price order, as Shopify does. `search.sort_by`
972
+ says which order applies and `search.sort_options` lists those three.
973
+ - **`'now' | date: '%N'` differs between calls.** Each `'now'` reads the clock again, as on
974
+ Shopify, so ids built from it no longer repeat. With a fixed `now` in the data, each read is one
975
+ nanosecond after the previous one: renders stay reproducible.
976
+
977
+ ## 0.1.16 - 2026-10-14
978
+
979
+ ### Added
980
+
981
+ - **`npx local-storefront check` finds what Shopify's strict Liquid parser rejects, and
982
+ `npx local-storefront check --fix` rewrites it.** Shopify is moving every store to a strict
983
+ parser, and a file it rejects can no longer be uploaded
984
+ (`Expected end_of_string but found pipe`). `npx local-storefront check` reports each tag and
985
+ output it rejects, with Shopify's message and the fix, in `.liquid` files and in the `liquid`
986
+ settings of JSON templates, section groups and `config/settings_data.json`.
987
+ - Where what the code meant is clear, `--fix` does it, and the report says how the output
988
+ changes: a filter in a `render` argument is computed into a variable on the line above, an
989
+ `and` in an argument is evaluated, `('X' | downcase)` is the string and not the variable
990
+ `X`, `reversed` after `limit` reverses, `=` in a condition compares, and the body of
991
+ `{% when 'x' and y %}` goes in `{% if y %}` (a `case` with an `else` becomes
992
+ `if`/`elsif`/`else`).
993
+ - Elsewhere it keeps today's output, as Shopify's own migration does, without the comment it
994
+ leaves in the file.
995
+ - A fix keeps the layout of the markup and is checked before it is written. Markup without a
996
+ fix is reported with the reason.
997
+ - `--format json` prints one JSON document, for tools and AI agents: each problem with its
998
+ code, file, line, markup and fix.
999
+
1000
+ See [strict
1001
+ Liquid](https://github.com/0xtlt/local-storefront/blob/main/docs/strict-liquid.md).
1002
+
1003
+ ### Changed
1004
+
1005
+ - **The documentation is organised by task.** The README is short: what local-storefront does,
1006
+ how to start, and a link per task. Each task has its guide in `docs/` (data, testing, strict
1007
+ Liquid, formatting, performance, storefront), next to two references: the
1008
+ [CLI](https://github.com/0xtlt/local-storefront/blob/main/docs/cli.md), with every option,
1009
+ exit code and output format, and the [control
1010
+ API](https://github.com/0xtlt/local-storefront/blob/main/docs/control-api.md).
1011
+ [docs/agents.md](https://github.com/0xtlt/local-storefront/blob/main/docs/agents.md) tells an
1012
+ AI agent how to drive local-storefront, and `llms.txt` lists the documentation for language
1013
+ models.
1014
+ - Messages name the subcommand to run (`the init command`, `run again with --fix`) rather
1015
+ than `lsf`, which is only the command of the installs that are not from npm.
1016
+
1017
+ ## 0.1.15 - 2026-10-14
1018
+
1019
+ ### Added
1020
+
1021
+ - **`lsf fmt`** formats the Liquid files of a theme the way Prettier does with Shopify's
1022
+ Liquid plugin, without Node: Liquid, HTML, the JSON of `{% schema %}`, the CSS of
1023
+ `<style>`, `{% style %}` and `{% stylesheet %}`, and the JavaScript of `<script>` and
1024
+ `{% javascript %}`. `lsf fmt --check` lists the files that are not formatted and fails if
1025
+ there are any, for CI. The 283 files of Shopify's Horizon theme are checked in 0.08 s, where
1026
+ Prettier takes 1.6 s.
1027
+ - It reads the configuration Prettier reads: `.prettierrc`, `.editorconfig` and
1028
+ `.prettierignore`. `--option printWidth=100` sets an option from the command line, and
1029
+ `--stdin` (with `--stdin-filepath`) formats what an editor sends. Prettier's options for
1030
+ JavaScript (`semi`, `trailingComma`, `arrowParens`, ...) apply too.
1031
+ - Files named one by one, as a commit hook names them, are formatted when they are `.liquid`
1032
+ files that `.prettierignore` does not name. A file is replaced in one step, with its
1033
+ permissions. A file that cannot be formatted is reported, and the others are formatted
1034
+ all the same.
1035
+
1036
+ See [what differs from Prettier](https://github.com/0xtlt/local-storefront/blob/main/docs/compatibility.md#formatting-lsf-fmt).
1037
+ - **Feature previews.** `--preview <name>` (or `LSF_PREVIEW`) turns on a feature preview of
1038
+ Shopify, as a development store can. Without it, a page renders as on a live store: a theme
1039
+ that uses a preview gets the error a live store gives, and `lsf` names the preview to turn
1040
+ on. The first one is `liquid-july-26`, Shopify's "Liquid July '26 changes":
1041
+ - **`{% block 'name', parameter: value %}content{% endblock %}`** renders
1042
+ `blocks/name.liquid` from a template or a layout. The block sees its parameters and its
1043
+ body, as `content` or `block.content`; a parameter named after a setting of its schema
1044
+ sets `block.settings`; arrays can be written in place. With it, Shopify's Skeleton theme
1045
+ renders.
1046
+ - **`{% partial 'name' %}`** and **`@shopify/partial-rendering`**: a theme that uses
1047
+ partials gets an import map for the module, and `partials.refresh('name')` swaps in the
1048
+ region from the server, keeping focus, form values and scroll, in loops and table rows
1049
+ too. Shopify has not published its own markup yet: lsf marks a partial with HTML comments.
1050
+
1051
+ ### Fixed
1052
+
1053
+ - A `file_reference` metafield or metaobject field that points to a video (`.mp4`, `.mov`,
1054
+ `.webm`, `.m4v`) is a video, as on Shopify: `sources`, `preview_image` and `video_tag`
1055
+ work, and `metafield_tag` prints a `<video>` as Shopify does. It was a plain file with only
1056
+ a `url`, so themes rendered it empty. The poster is the `.jpg` of the same name. A `.mov` is
1057
+ announced as `video/mp4`, as Shopify announces the MP4 it makes of it, so that Chrome and
1058
+ Firefox play it; the `video` setting does the same.
1059
+ - Files of `files/` are served in parts when the browser asks with a `Range` header. Safari
1060
+ played no video without it, and no browser could seek in one. Only the part asked for is
1061
+ read, and a part is never compressed. `.m4v` files are served as `video/mp4`.
1062
+ - Metafield values are the objects Shopify gives for four more types. A `color` is a color
1063
+ (`.red`, `.rgb`...), not text. An `article_reference` is the article, named `blog/article`
1064
+ or by the handle of an article that only one blog has. Every measurement type (`distance`,
1065
+ `temperature`, `duration`...) has a `type`, a `value` and a `unit`, and `metafield_tag`
1066
+ prints it with two decimals at most, where only `weight`, `volume` and `dimension` were
1067
+ printed. A `money` has the `currency` it is written in, and is formatted in it: `€5,00 EUR`
1068
+ in a store that sells in dollars.
1069
+ - `request.locale.direction` (and that of every locale) is `rtl` for Arabic, Hebrew, Persian
1070
+ and the other languages written right to left, and follows the script when the locale names
1071
+ one (`pa-Arab`). Horizon sets `dir` on `<html>` from it.
1072
+ - The `applicable_disclosures` filter keeps every disclosure, as Shopify does when it cannot
1073
+ tell the buyer's country. It was an unknown filter that printed a warning.
1074
+
1075
+ ## 0.1.14 - 2026-10-06
1076
+
1077
+ ### Fixed
1078
+
1079
+ - `lsf serve` gives back the memory it used to encode images. A server left running for
1080
+ three hours on a large theme held 1 GB. After every image of that theme is asked for,
1081
+ 277 MB are left where 664 MB were, and pages render as fast as before.
1082
+
1083
+ ## 0.1.13 - 2026-10-05
1084
+
1085
+ ### Added
1086
+
1087
+ - **`Server-Timing` on every response**, as on a Shopify storefront: `processing` for the
1088
+ whole, then `render`, `template`, `layout`, `compress` and `throttle`.
1089
+ `lsf serve --timings` also names every section and every theme block.
1090
+ - **`lsf profile <path>`** renders one page and prints what rendered what: the template, the
1091
+ layout, each section, its blocks and their snippets, with the time of each. `--lines` goes
1092
+ down to the tag.
1093
+ - **`lsf profile --points`** counts what the page asks of a store instead of the time it
1094
+ takes here: a product, a metafield, a search. A local server reads a metafield as fast as
1095
+ a title, Shopify does not. `--cost` changes what each kind of thing costs.
1096
+ - **Flame graphs.** `/__lsf/profile?path=<path>&html=1` opens the profile in speedscope, the
1097
+ viewer that `shopify theme profile` opens. It is embedded: nothing is fetched.
1098
+ - `/__lsf/timings` adds up the renders so far by template, section and block.
1099
+
1100
+ ## 0.1.12 - 2026-10-04
1101
+
1102
+ The responses of `lsf serve` now weigh what a Shopify storefront sends, so that the sizes a
1103
+ browser or an audit reports are those of the real store.
1104
+
1105
+ ### Added
1106
+
1107
+ - **Minified stylesheets and scripts**, by the rules Shopify follows, with source maps: the
1108
+ tools of a browser still show the files as written. `--no-minify` serves them as they are.
1109
+ - **Images in the lightest format the browser reads.** AVIF, WebP or the format of the file,
1110
+ chosen from the `Accept` header as Shopify's image CDN does.
1111
+ - **`Cache-Control`** as Shopify sends it for each kind of response: pages, assets, images,
1112
+ fonts, the cart.
1113
+ - **The resource hints Shopify adds.** The `Link` header preloads the stylesheets and the
1114
+ scripts that block rendering, and connects to the origins they come from.
1115
+
1116
+ ### Fixed
1117
+
1118
+ - The `Link` header of `preload: true` and `preload_tag` is written as Shopify writes it,
1119
+ one header with quoted values. A browser could not read the previous form for an image
1120
+ with a `srcset`.
1121
+ - A font preloaded with `preload_tag` gets `crossorigin`, without which a browser does not
1122
+ use it.
1123
+ - The `?v=` of a `.liquid` asset changes with the theme settings it is rendered with, not
1124
+ only with its source.
1125
+ - A placeholder image served from the cache says that it is one (`x-lsf-placeholder`).
1126
+
1127
+ ## 0.1.11 - 2026-10-03
1128
+
1129
+ ### Added
1130
+
1131
+ - **Responses are compressed** with Brotli or gzip for the clients that accept it, as on a
1132
+ storefront. A 420 KB collection page of Horizon becomes 53 KB. `--no-compression` sends
1133
+ responses as they are.
1134
+
1135
+ ### Fixed
1136
+
1137
+ - `{{-` and `{%-` trim whitespace the way storefronts do, which is not quite what the
1138
+ language says: when the text before one is nothing but whitespace, its first character
1139
+ stays. Line breaks in a `<title>` or between two outputs now match Shopify's.
1140
+ - The rules of `robots.txt` are on their own lines, those a theme adds with
1141
+ `{{ 'Disallow: ...' }}` included.
1142
+
1143
+ ## 0.1.10 - 2026-10-03
1144
+
1145
+ ### Changed
1146
+
1147
+ - **`--live-reload` goes through a WebSocket.** A page reloads about 100 ms after a save,
1148
+ where it asked the server every 700 ms. It still does when the socket cannot open, behind
1149
+ a proxy for example.
1150
+ - A page survives a restart of the server, and reloads if the files changed meanwhile.
1151
+ - A save or a build that writes several files reloads the pages once.
1152
+ - Only the directories a theme is made of and the data directory are watched, which is much
1153
+ faster for a theme that keeps `node_modules` next to its sources. A data directory outside
1154
+ the theme (`--data`) now reloads the pages. A file outside those directories no longer
1155
+ does.
1156
+
1157
+ ## 0.1.9 - 2026-10-03
1158
+
1159
+ ### Added
1160
+
1161
+ - **Selling plans.** `selling_plan_groups` in the data describes subscriptions. Products and
1162
+ variants expose their groups and allocations, `?selling_plan=<id>` selects a plan, and
1163
+ `/cart/add.js` and `/cart/change.js` take `selling_plan`.
1164
+ - **Store pickup.** `locations` in the data are the places that stock the products, and
1165
+ variants have `store_availabilities`. `/variants/<id>?section_id=` renders a section for a
1166
+ variant, which is how Dawn loads pickup availability.
1167
+ - **Native swatches.** `swatches` in the data gives a color or an image to option values:
1168
+ `product_option_value.swatch` and `filter_value.swatch` are filled.
1169
+ - The demo store has a subscription, two locations and swatches for its colors.
1170
+
1171
+ ## 0.1.8 - 2026-10-02
1172
+
1173
+ ### Changed
1174
+
1175
+ - Without `--port`, `lsf serve` listens on the next free port when 9292 is taken, up to
1176
+ 9391, and says which. A port given with `--port` is still that port or an error.
1177
+
1178
+ ## 0.1.7 - 2026-10-02
1179
+
1180
+ The Liquid engine is now checked against
1181
+ [liquid-spec](https://github.com/Shopify/liquid-spec), Shopify's test suite of the language:
1182
+ 4,608 of the 4,630 specs that apply pass. The others are listed in
1183
+ [compatibility](https://github.com/0xtlt/local-storefront/blob/main/docs/compatibility.md).
1184
+
1185
+ ### Added
1186
+
1187
+ - `self`, the object that reads the variables of the current scope (`self[name]`).
1188
+
1189
+ ### Fixed
1190
+
1191
+ - `truncate` crashed when given the smallest 64-bit integer.
1192
+ - Comparing with a float that is not a number raised an error instead of being false.
1193
+ - `replace`, `replace_first` and `remove` ignored the back-references of the replacement
1194
+ (`\'`, `` \` ``, `\+`, `\k<name>`).
1195
+ - Floats from 1e15 on were printed in fixed notation, and some JSON numbers were read one
1196
+ bit off.
1197
+ - `{% assign x ) = 1 %}` was accepted, and `{% docEXTRA %}` was taken for a nested `doc`.
1198
+ - An error at the end of a `{% liquid %}` tag was reported one line too far.
1199
+ - A contact or newsletter form given an `id` posts back to that id
1200
+ (`action="/contact#ContactForm"`), as on Shopify.
1201
+
1202
+ ## 0.1.6 - 2026-10-02
1203
+
1204
+ ### Added
1205
+
1206
+ - **Customer accounts.** `/account` shows who is logged in and lets the visitor become any
1207
+ customer of the data. A theme with `templates/customers` keeps its own pages. A customer
1208
+ is named by email, `default` or `none`: in the data, in a session, with `/__lsf/login` or
1209
+ with `lsf serve --customer`.
1210
+ - **B2B.** Companies with their locations, the customers that buy for them, and the objects
1211
+ that go with them: `customer.b2b?`, `current_company`, `current_location`. Catalogs are
1212
+ not simulated.
1213
+ - **`/robots.txt` and `/sitemap.xml`.** `robots.txt` renders `templates/robots.txt.liquid`,
1214
+ or Shopify's default rules.
1215
+ - **`/password`** shows the password page of the theme and accepts `shop.password`.
1216
+ - `placeholder_svg_tag` draws Shopify's own illustrations for the three names that are
1217
+ published under a free license.
1218
+
1219
+ ### Changed
1220
+
1221
+ - A password in the data no longer locks the storefront: nothing redirects to `/password`.
1222
+
1223
+ ## 0.1.5 - 2026-10-02
1224
+
1225
+ ### Added
1226
+
1227
+ - **`lsf serve --throttle`** answers requests late, so that loading states can be tested:
1228
+ every request (`--throttle 300ms`) or one kind of request
1229
+ (`--throttle cart=500ms,cart-add=1s`).
1230
+ - **Presets.** `simulated` answers as late as a Shopify storefront on a good connection,
1231
+ `slow` as on a slow mobile connection. A preset can be adjusted:
1232
+ `--throttle simulated,cart-add=2s`.
1233
+ - A session can have a throttle of its own through `PUT /__lsf/session`: one test can be
1234
+ slow without slowing the suite.
1235
+
1236
+ ### Changed
1237
+
1238
+ - The README is written around what a user does, and documents every option of `lsf serve`.
1239
+
1240
+ ## 0.1.4 - 2026-10-02
1241
+
1242
+ ### Added
1243
+
1244
+ - **The scripts Shopify adds to a page.** `content_for_header` follows Shopify's, with the
1245
+ `Shopify` object and `Shopify.loadFeatures`, and pages get `ShopifyAnalytics.meta` and
1246
+ `Shopify.analytics`.
1247
+ - **`Shopify.actions`** (`getCart`, `updateCart`, `openCart`) and the `shopify:cart:*`
1248
+ events, written against the local cart.
1249
+ - **`Shopify.customerPrivacy`**, loaded with `consent-tracking-api`: consent is kept in the
1250
+ browser.
1251
+ - The `standard_event_data` filter, for products, collections and the cart.
1252
+ - A page can render a section that belongs to another template, which a cart drawer does to
1253
+ load recommendations.
1254
+
1255
+ ### Changed
1256
+
1257
+ - The `cart` cookie is set when a cart is created, `/cart.js` bundles sections, and
1258
+ `/cart/update.js` removes the attributes set to null.
1259
+
1260
+ ## 0.1.3 - 2026-10-02
1261
+
1262
+ ### Fixed
1263
+
1264
+ - `image_url` accepts decimal sizes, which themes compute with `width | times: ratio`. They
1265
+ raised "invalid integer", and the image was left out.
1266
+
1267
+ ## 0.1.2 - 2026-10-02
1268
+
1269
+ ### Added
1270
+
1271
+ - **`lsf` is on npm.** `npm install --save-dev local-storefront` gives a project the
1272
+ command, with the binary of its platform only. Run it with `npx local-storefront`:
1273
+ `npx lsf` can download an unrelated package of that name.
1274
+
1275
+ ## 0.1.1 - 2026-10-02
1276
+
1277
+ ### Changed
1278
+
1279
+ - **Rendering scales with the number of clients.** On a 14-core machine, the collection page
1280
+ of Horizon goes from 70 pages per second to 430 with one client, and from 107 to 3435
1281
+ with fourteen. The HTML is the same to the byte.
1282
+ - With `--static`, an asset edited while the server runs keeps its `?v=`: nothing is read
1283
+ from disk twice.
1284
+
1285
+ ## 0.1.0 - 2026-10-02
1286
+
1287
+ First release. `lsf` renders a Shopify theme without calling Shopify, so that end-to-end
1288
+ tests run against a local server with no rate limit.
1289
+
1290
+ - **A port of Shopify's Liquid**, checked against the reference gem by 548 differential
1291
+ tests, with Shopify's objects, filters and tags, sections, blocks, JSON templates and
1292
+ locales.
1293
+ - **Store data as JSON files**, with a JSON Schema and a validation that explains mistakes.
1294
+ - **`lsf serve`**: storefront pages, the Cart Ajax API, the Section Rendering API, forms,
1295
+ and images transformed and served locally.
1296
+ - **A control API** that gives each test session its own cart, customer and data.
1297
+ - The other commands: `render`, `validate`, `check`, `init`, `routes`, `schema`, `docs`.
1298
+ - One binary for macOS, Linux (glibc 2.17 or later, and static musl) and Windows, on x86-64
1299
+ and ARM64.