local-storefront 0.3.0-rc.1 → 0.3.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,133 @@
3
3
  What changed in each version of `lsf`, for someone who uses it. The section of a version is
4
4
  what its [GitHub release](https://github.com/0xtlt/local-storefront/releases) says.
5
5
 
6
+ ## 0.3.0-rc.3 - 2026-11-12
7
+
8
+ Third release candidate of 0.3.0. The local checkout applies the code discounts a backend makes
9
+ through the Admin API, and the session token of checkout and customer account UI extensions is
10
+ the one Shopify gives, signed with the client secret. A backend that read lsf's old token needs
11
+ a change: see Breaking changes.
12
+
13
+ ### Breaking changes
14
+
15
+ What to change when you upgrade from 0.3.0-rc.2.
16
+
17
+ - **The session token of a UI extension has Shopify's claims.** `dest` is the shop's bare
18
+ domain (`local-supply-co.myshopify.com`), no longer lsf's origin; there is no `iss` and no
19
+ `sid`; it lives five minutes; `sub` is there only when a customer is logged in. It is signed
20
+ with HS256 and the client secret, so a backend now verifies it as it verifies Shopify's, and
21
+ one that skipped the verification for lsf can stop doing so.
22
+
23
+ ### Added
24
+
25
+ #### The checkout of an app
26
+
27
+ - **`DiscountCodeBasic` and `DiscountCodeFreeShipping` codes apply at the checkout.** A code made
28
+ with `discountCodeBasicCreate` or `discountCodeFreeShippingCreate` takes an amount or a
29
+ percentage off the order, off listed products, variants or collections, or off each item, or
30
+ makes the rates up to `maximumShippingPrice` free in the countries of `destination`. It is taken
31
+ while the discount is active by its dates, under its `usageLimit`, for a buyer of its `context`
32
+ (all, listed customers, members of listed segments) who has not used it when it
33
+ `appliesOncePerCustomer`, and for a cart that meets its `minimumRequirement`. One such code
34
+ applies per checkout, with the Function discounts. The order and its `orders/create` and
35
+ `orders/paid` carry it in `discount_codes` and `discount_applications`, and its
36
+ `asyncUsageCount` goes up. `combinesWith`, buy X get Y codes and a `context` of markets are not
37
+ read.
38
+
39
+ ### Fixed
40
+
41
+ #### The checkout of an app
42
+
43
+ - **`shopify.sessionToken.get()` gives a token a backend can verify.** The page asks lsf for it
44
+ (`POST /__lsf/extensions/<handle>/session-token`), and lsf signs it with the client secret of
45
+ the Admin API (`--client-secret`, `SHOPIFY_API_SECRET`, the app's `.env`), which stays on the
46
+ server. `get()` rejects with the reason when lsf gives no token.
47
+
48
+ ## 0.3.0-rc.2 - 2026-11-12
49
+
50
+ Second release candidate of 0.3.0. It fixes what was reported on 0.2.0 and 0.3.0-rc.1, each
51
+ change checked against what Shopify does, and the store data gains provinces, currencies,
52
+ market languages, URLs per market and translated metaobjects. Some theme tests and store data
53
+ need a change: see Breaking changes.
54
+
55
+ ### Breaking changes
56
+
57
+ What to change when you upgrade from 0.3.0-rc.1.
58
+
59
+ - **A theme block rendered more than once on a page gets Shopify's numbered ids.** The first
60
+ render keeps `<prefix>__<key>`, the next ones get `-1`, `-2`, … in `block.id` and in
61
+ `id="shopify-block-…"`. A block rendered once is unchanged. Theme tests that snapshot HTML with
62
+ a repeated block need their snapshots updated.
63
+ - **A `rating` metafield's `value`, `scale_min` and `scale_max` are strings, as on Shopify.**
64
+ `{{ m.value.scale_max | json }}` gives `"5.0"`, no longer `5.0`. A number written in the store
65
+ data is kept as written (`5` gives `"5"`).
66
+ - **A reference metafield lsf can't resolve is a `validate` error.** A GID now names the entity
67
+ with that id; a GID or any other value that names nothing, which used to pass and render `nil`,
68
+ fails `validate` with the form to write (`use "faq/size"`).
69
+ - **A country's `currency` is what its buyers pay in.** It was only displayed. With no `rate`,
70
+ prices are converted at a rate of 1: give the market a `rate` or fixed `prices`.
71
+
72
+ ### Added
73
+
74
+ #### Store data and the storefront
75
+
76
+ - **Provinces.** `data-provinces` of `country_option_tags` and `all_country_option_tags` lists
77
+ the provinces Shopify lists for a country (37 countries), as Shopify prints them.
78
+ `localization.countries[].provinces` limits a country to those the store ships to. Addresses
79
+ get their province's name from its code and the reverse, the checkout has a province
80
+ selector, and `shopify_common.js` has `Shopify.CountryProvinceSelector`.
81
+ - **Currencies per market.** `localization.markets[].currency`, `rate` and fixed `prices` price
82
+ the storefront, the cart, `/cart.js`, the money filters, `Shopify.currency` and the checkout
83
+ in the currency of the visitor's country. Functions read the rate as
84
+ `presentmentCurrencyRate`.
85
+ - **The URL picks the country.** `?country=XX` sets it (a `301` to the country's subfolder when
86
+ its market has one). `localization.markets[].subfolder_suffix` serves a market under
87
+ `/<language>-<suffix>/` (`/en-ca/`), with its links, `request.locale.root_url` and
88
+ `Shopify.routes.root` under it. The localization form moves the visitor to that subfolder.
89
+ - **Languages per market.** `localization.markets[].languages` (default first) drives
90
+ `country.available_languages` and `localization.available_languages`, and the subfolders a
91
+ market has. Switching country keeps the current language when the new market offers it, and
92
+ uses the market's default otherwise. Without `languages`, every country gets every language.
93
+ - **Translated metaobjects.** A metaobject entry takes `translations`, by language then field
94
+ key: localized routes show them, field by field, with `fr-CA` falling back to `fr`. `validate`
95
+ checks them, and the Admin API's `translationsRegister` and `translationsRemove` read and write
96
+ them. Translations into a locale the Admin API unpublishes leave the storefront until it is
97
+ published again.
98
+
99
+ #### The command line
100
+
101
+ - **`validate` warns when the theme names a menu the store data lacks.** `default_menu` for
102
+ `main-menu` and `footer`, which lsf makes up, and `missing_menu` for the others. `render` warns
103
+ and the `--live-reload` panel shows a note when a page renders one of lsf's default menus.
104
+
105
+ ### Fixed
106
+
107
+ #### Themes and the storefront
108
+
109
+ - **Cart lines have `index`, their 0-based position**, so Horizon's remove button removes the
110
+ line clicked, not the first one.
111
+ - **`/cart/change.js` keeps to the stock, as `/cart/add.js` does.** More than a tracked variant's
112
+ stock caps the line and answers `422`, with Shopify's 2025 descriptions ("Only N items were
113
+ added to your cart due to availability.", "The maximum quantity of this item is already in
114
+ your cart.").
115
+ - **Minified stylesheets mean what their source means.** `translate`, `rotate` and `scale` stay
116
+ apart from `transform`, `font` and `transition` stay before what they reset, and
117
+ `border-image: none` is kept.
118
+ - **A minified asset links its source map at its own `?v=`**, as Shopify's CDN does, so the map
119
+ of an edited file is fetched again.
120
+ - **A reference metafield given as a GID renders the entity it names.** It rendered `nil`.
121
+ - **An object or list metafield can be written as its Admin API JSON text** (the `value` the
122
+ Admin API gives), so a value can be pasted from the API as is.
123
+
124
+ #### The command line
125
+
126
+ - **Paths in messages no longer start with `\\?\` on Windows.**
127
+ - **On Windows, path options read a Git Bash path (`/c/Users/me`) as `C:\Users\me`.** A missing
128
+ data directory given that way no longer suggests `init` with it. `render` and `profile` refuse
129
+ a route Git Bash rewrote into `C:/Program Files/Git/…`, and name `MSYS_NO_PATHCONV=1`.
130
+ - **`lsf app check` with no scenario names the extensions that are not built** in its summary,
131
+ with `lsf app build`, instead of a `fails` line for each. It still exits with `1`.
132
+
6
133
  ## 0.3.0-rc.1 - 2026-11-12
7
134
 
8
135
  lsf now serves a local GraphQL Admin API (version 2026-10) and delivers an app's webhooks to its
@@ -877,8 +877,14 @@ simulate: give a test the bytes through `stagedUploadsCreate`.
877
877
  of the resources. lsf translates products, collections, pages, blogs, articles, metafields and
878
878
  metaobject fields of translatable types, policies, product options and their values, and menus.
879
879
  The digest is the SHA-256 of the content, as Shopify's; the locale must be one of the shop's
880
- other than its primary one. A translation is `outdated` once its resource changed. Translations
881
- are not shown on the storefront.
880
+ other than its primary one. A translation is `outdated` once its resource changed. The
881
+ translations of a metaobject are those of the store data ([metaobject
882
+ translations](data-format.md#translations-of-metaobjects)): `translatableResource` reads them,
883
+ `translationsRegister` and `translationsRemove` change them, and the storefront shows them on the
884
+ routes of their language (`/fr/...`). Those of a market, and of the other resources, are not
885
+ shown on the storefront. A locale unpublished (`shopLocaleUpdate`) keeps its translations in the
886
+ Admin API but leaves the storefront with them, until it is published again; `shopLocaleDisable`
887
+ deletes them.
882
888
 
883
889
  **Webhooks.** `metafield_definitions/create`, `/update` (also pin and unpin), `/delete`;
884
890
  `metaobjects/create`, `/update`, `/delete`; `products/update` when a product's or a variant's
@@ -1073,8 +1079,11 @@ Function by `functionHandle` (or the deprecated `functionId`), which must be a d
1073
1079
  of the app; their `metafields` are the Function's configuration, checked as `metafieldsSet`
1074
1080
  checks them (a definition of the `DISCOUNT` owner type applies). The store sync writes an active
1075
1081
  app discount back to the store data's `checkout.discounts` (its Function, title, first code,
1076
- classes and metafields), so the local checkout runs it. Shopify's own types (basic, buy X get Y,
1077
- free shipping, automatic and code) are kept as records: the local checkout runs Functions only.
1082
+ classes and metafields), so the local checkout runs it. Of Shopify's own types, the local
1083
+ checkout applies the codes of `DiscountCodeBasic` and `DiscountCodeFreeShipping` itself, and
1084
+ counts the orders placed with them in `asyncUsageCount`
1085
+ ([what the checkout sees](admin.md#what-the-storefront-and-the-checkout-see)); buy X get Y and
1086
+ the automatic ones are kept as records.
1078
1087
  Their `summary` is the admin's: what the discount takes off, then its minimum, in the shop's
1079
1088
  money format (`$5.00 off entire order • Minimum purchase of $50.00`, `10% off 2 products`, `Buy
1080
1089
  2 items, get 1 item free`, `Free shipping on all products`), and `shortSummary` its first part.
package/docs/admin.md CHANGED
@@ -655,7 +655,7 @@ and delivery customizations, validations, cart transforms, fulfillment constrain
655
655
  app discounts (`DiscountAutomaticApp`, `DiscountCodeApp`), whether the app created them or the
656
656
  store data declared them (`checkout.functions.<handle>.enabled`, `checkout.discounts`). The
657
657
  answer counts them (`removedFunctionOwners`). The discounts of Shopify's own types (a
658
- `DiscountCodeBasic` the app made) stay. While the app is uninstalled, the shop's checkout runs
658
+ `DiscountCodeBasic` the app made) stay, and their codes still apply at the checkout. While the app is uninstalled, the shop's checkout runs
659
659
  none of its Functions, whatever a scenario declares or names in its `extensions`: the
660
660
  checkout's Functions dialog lists each with "the app is not installed on the shop". A reinstall
661
661
  gets none of those objects back, as on Shopify, where a reinstalled app "had to register them
@@ -1624,6 +1624,24 @@ What the backend changes through the Admin API is what the storefront and the
1624
1624
  extensions on the order status page (`appMetafields`, `owner_type = "ORDER"`);
1625
1625
  - an app discount it creates (`discountAutomaticAppCreate`, `discountCodeAppCreate`) runs its
1626
1626
  Function at the checkout, with the metafields it was given, while it is active;
1627
+ - a code discount of Shopify's own types it creates (`discountCodeBasicCreate`,
1628
+ `discountCodeFreeShippingCreate`) applies at the checkout once the buyer enters one of its
1629
+ codes, without a Function: an amount or a percentage off the order, off the products,
1630
+ variants and collections it lists (`appliesOnEachItem` included), or free shipping. The
1631
+ checkout takes the code while the discount is `ACTIVE` by its dates, its `usageLimit` is not
1632
+ reached, the buyer is one of its `context` (all buyers, the customers it lists, the members of
1633
+ the segments it lists: a guest is the customer whose email they gave), has not used it when it
1634
+ `appliesOncePerCustomer`, and the cart meets its `minimumRequirement` (the subtotal or the
1635
+ quantity of the items it applies to, before discounts); free shipping makes free the rates up
1636
+ to its `maximumShippingPrice`, to the countries of its `destination`. Otherwise the page says
1637
+ "Enter a valid discount code", and the state's `discountCodes` says why (`reason`). The order
1638
+ carries the code (`discountCodes`, a `DiscountCodeApplication`, the allocations), so do the
1639
+ `discount_codes` and `discount_applications` of `orders/create` and `orders/paid`, and the
1640
+ discount's `asyncUsageCount` (and its code's) goes up by one at once. One code of these types
1641
+ applies per checkout (lsf does not read `combinesWith`), and it stacks with the discounts of
1642
+ the Functions; of the shipping discounts, the best one applies. Not read: a `context` of
1643
+ markets (every buyer), `appliesOnOneTimePurchase` and `appliesOnSubscription`. Buy X get Y
1644
+ codes and Shopify's automatic discounts stay records;
1627
1645
  - the shipping rates of the general delivery profile it changes are the checkout's delivery
1628
1646
  options (see [delivery](admin-reference.md#inventory-transfers-fulfillment-and-delivery));
1629
1647
  - a theme file it writes to the main theme (`themeFilesUpsert`, `themeFilesCopy`,
@@ -1637,10 +1655,10 @@ What the backend changes through the Admin API is what the storefront and the
1637
1655
  | Syncs | Does not sync |
1638
1656
  |---|---|
1639
1657
  | Products: title, handle, description, vendor, type, tags, status and publication, options, variants (price, SKU, barcode, inventory policy, weight), stock per location, media from `files/`, collections and selling plan groups they are in | Orders and what belongs to them (`orderCreate`, draft orders, fulfillments, refunds, returns): the account pages show the orders of the data and of the checkout |
1640
- | Collections: title, handle, description, image, products in order | Discounts that are not app discounts (`DiscountCodeBasic`, …): the local checkout runs Functions only; of an app discount, `combinesWith`, usage limits and every code but the first |
1658
+ | Collections: title, handle, description, image, products in order | Discounts of Shopify's own types: the local checkout reads the code ones (`DiscountCodeBasic`, `DiscountCodeFreeShipping`) from the Admin API itself (above), and leaves the others (`…Bxgy`, the automatic ones) out; of an app discount, `combinesWith`, usage limits and every code but the first |
1641
1659
  | Customers: email, names, phone, tags, marketing consent, account state, addresses, their company | Gift cards the Admin API creates (changes to the data's cards sync) |
1642
- | Companies and their locations, pages, blogs and articles, menus, locations, metaobjects, selling plan groups | SEO, product categories, bundle components and combined listings (a bundle is a product of its own there), collection rules and sources (a changed automated collection becomes a manual one), product feeds, point of sale |
1643
- | Metafields of all of these, of the shop and of markets; markets, their countries and their currency; the published languages | Metafield and metaobject definitions, files, translations, publications other than the Online Store; catalogs, price lists, quantity rules, web presences, channels |
1660
+ | Companies and their locations, pages, blogs and articles, menus, locations, metaobjects and their translations, selling plan groups | SEO, product categories, bundle components and combined listings (a bundle is a product of its own there), collection rules and sources (a changed automated collection becomes a manual one), product feeds, point of sale |
1661
+ | Metafields of all of these, of the shop and of markets; markets, their countries and their currency; the published languages | Metafield and metaobject definitions, files, translations of other resources or for a market, publications other than the Online Store; catalogs, price lists, quantity rules, web presences, channels |
1644
1662
  | Shop: name, email, description, policies | Domains, currency, time zone, plan |
1645
1663
 
1646
1664
  Each family of the [reference](admin-reference.md#what-lsf-simulates) says what of it syncs.
package/docs/checkout.md CHANGED
@@ -1117,6 +1117,22 @@ discounts on the subtotal they left, then shipping. Within a class:
1117
1117
  off −€0.00" while its settings are not set. A candidate of more than 0 that finds nothing left
1118
1118
  to take off (a line another discount made free) is not applied.
1119
1119
 
1120
+ **Codes of Shopify's own discount types.** With the [Admin API](admin.md), a code of a
1121
+ `DiscountCodeBasic` or a `DiscountCodeFreeShipping` the backend created applies without a
1122
+ Function ([what the checkout sees](admin.md#what-the-storefront-and-the-checkout-see)): an
1123
+ amount off the order is an order discount, one off listed products, variants or collections (or
1124
+ off each item) a product discount, free shipping a shipping discount, and each takes its place
1125
+ among the discounts of the Functions by the rules above. The code is checked when the
1126
+ checkout is computed: the discount's dates and usage limit, its buyers, one use per customer,
1127
+ its minimum (on the items it applies to, before discounts), and for free shipping the country
1128
+ of the address (a code is taken before there is one). A refused code shows "Enter a valid
1129
+ discount code", and its entry of `discountCodes` in the state has a `reason`
1130
+ (`"its minimum purchase is 50.00 USD, and the items it applies to come to 32.00 USD"`). One code
1131
+ of these types applies per checkout, the first one entered that qualifies: a second is refused,
1132
+ as on Shopify when a discount combines with no other (lsf does not read `combinesWith`). Its
1133
+ `DiscountApplication` is `ACROSS` (`EACH` off each item), `ALL` for the order and `ENTITLED`
1134
+ for listed items; free shipping is 100% off the shipping line, `EACH`.
1135
+
1120
1136
  An amount is shared between the lines in proportion to what they cost, to the cent, and the
1121
1137
  allocations of the lines (`discountAllocations`, in the page and for the Functions that run
1122
1138
  later) add up to the discount.
@@ -1298,6 +1314,39 @@ of checkout UI extensions has no such type): an `ORDER` declaration reads nothin
1298
1314
  a warning. Shopify lists `CART` as an owner type of checkout UI extensions only and `ORDER` of
1299
1315
  customer account UI extensions only; lsf takes both on either, as one extension can target both.
1300
1316
 
1317
+ ### Session tokens
1318
+
1319
+ `shopify.sessionToken.get()`, in a checkout or a customer account extension, gives the token
1320
+ Shopify gives, which the backend verifies as it verifies Shopify's (`authenticate.public.checkout`
1321
+ and `authenticate.public.customerAccount` of Shopify's libraries, `decodeSessionToken`): an
1322
+ HS256 JWT signed with the client secret of the [Admin API](admin.md#start)
1323
+ (`--client-secret`, `SHOPIFY_API_SECRET` of the environment or of the app's `.env`, else
1324
+ `lsf-local-client-secret`; the same without the admin part, without `--client-secret`). Its
1325
+ claims are those of shopify.dev's Session Token API, for
1326
+ [checkout](https://shopify.dev/docs/api/checkout-ui-extensions/latest/target-apis/platform-apis/session-token-api)
1327
+ and [customer account](https://shopify.dev/docs/api/customer-account-ui-extensions/latest/target-apis/platform-apis/session-token-api)
1328
+ extensions:
1329
+
1330
+ ```json
1331
+ {
1332
+ "dest": "local-supply-co.myshopify.com",
1333
+ "aud": "<client id>",
1334
+ "exp": 1794511949,
1335
+ "nbf": 1794511649,
1336
+ "iat": 1794511649,
1337
+ "jti": "7f63880b-b45d-49d4-beb1-af926c85653c",
1338
+ "sub": "gid://shopify/Customer/6523834743460"
1339
+ }
1340
+ ```
1341
+
1342
+ `dest` is the shop's domain, bare, as in shopify.dev's example (the session token of the admin
1343
+ has `https://`): the `permanent_domain` of the store data, the default shop of the Admin API.
1344
+ `aud` is the client id, the token lives five minutes, `jti` is a new UUID each time, and `sub`
1345
+ is there only when a customer is logged in (whether the app may read customer data is not
1346
+ checked). There is no `iss` and no `sid`. The page asks lsf for the token
1347
+ (`POST /__lsf/extensions/<handle>/session-token`, [control API](control-api.md#apps-checkout-and-admin-api))
1348
+ and hands it to the extension: the secret never reaches the browser.
1349
+
1301
1350
  ## Run a Function without a server
1302
1351
 
1303
1352
  ```bash
@@ -1505,7 +1554,7 @@ customization runs on them.
1505
1554
  | `shippingAddress` | `address`: `firstName`, `lastName`, `company`, `address1`, `address2`, `city`, `countryCode`, `provinceCode`, `zip`, `phone` | Sets the address. A country of the store becomes the country of the session. A `countryCode` that is not a country (ISO 3166-1 alpha-2, or `XK`, `AC`, `TA` as Shopify takes them; not `ZZ`) is refused and changes nothing. The store data has no shipping zones: any real country is taken. |
1506
1555
  | `deliveryOption` | `group` (`group-1`), `handle` | Chooses a shipping rate. |
1507
1556
  | `paymentMethod` | `handle` | Chooses a payment method. No handle clears it. |
1508
- | `discountCode` | `action` (`add` or `remove`), `code` | Enters or takes back a code. |
1557
+ | `discountCode` | `action` (`add` or `remove`), `code` | Enters or takes back a code. Each entered code is in the `discountCodes` of the state, `applicable` or not; a code of a discount of Shopify's own types that was refused has a `reason` ([how discounts stack](#how-discounts-stack)). |
1509
1558
  | `cartLines` | `change`: `addCartLine`, `removeCartLine` or `updateCartLine` of the UI extension API | Changes the cart of the session, the one the theme shows. |
1510
1559
  | `attribute` | `change`: `updateAttribute` or `removeAttribute`, with `key` and `value` | Changes the attributes of the cart. |
1511
1560
  | `note` | `change`: `updateNote` (`note`) or `removeNote` | Changes the note of the cart. |
@@ -1564,7 +1613,10 @@ The browser tests of a theme in CI: [testing](testing.md#in-ci).
1564
1613
  - Discounts have no `conditions` and no combinations: all of them stack, as described in
1565
1614
  [how discounts stack](#how-discounts-stack). Shopify lets the merchant choose which discounts
1566
1615
  combine, and applies the best of those that cannot; here all the product and order discounts
1567
- combine, and of the shipping discounts the best one applies.
1616
+ combine, and of the shipping discounts the best one applies. Of Shopify's own discount types,
1617
+ only the codes of `DiscountCodeBasic` and `DiscountCodeFreeShipping` apply, one per checkout,
1618
+ and only with the Admin API (which makes them): not buy X get Y, not the automatic ones, not a
1619
+ `context` of markets nor `appliesOnSubscription`.
1568
1620
  - Tax is one rate on the discounted subtotal, with no tax lines.
1569
1621
  - No Storefront API: `query` in an extension rejects. No Customer Account API either: a customer
1570
1622
  account extension reads the account through its `shopify` global only.
@@ -1592,8 +1644,14 @@ store before a release:
1592
1644
 
1593
1645
  - **Discount combinations and conditions**: lsf combines every product and order discount
1594
1646
  ([how discounts stack](#how-discounts-stack)).
1595
- - **Currencies and price lists**: one price list, in the shop's currency; a buyer abroad pays it
1596
- too ([what it does not do](#what-it-does-not-do), [markets](compatibility.md#known-differences)).
1647
+ - **Codes of Shopify's own discount types**: one applies per checkout whatever its
1648
+ `combinesWith`, its minimum is checked on the items it applies to before any discount, and a
1649
+ refused code says lsf's reason, where Shopify's checkout has messages of its own.
1650
+ - **Currencies and price lists**: a buyer pays in the currency of the country of the shipping
1651
+ address (else of the one selected on the storefront), at its market's `rate` or fixed `prices`
1652
+ ([localization](data-format.md#localization)); the Functions read that rate as
1653
+ `presentmentCurrencyRate`. Shopify's rounding rules of converted prices and B2B catalogs are
1654
+ not applied ([markets](compatibility.md#known-differences)).
1597
1655
  - **B2B catalogs, pickup points, local pickup, fulfillment constraints and order routing**: not
1598
1656
  run.
1599
1657
  - **Network access**: the request JWT and Shopify's cache of responses
package/docs/cli.md CHANGED
@@ -14,6 +14,14 @@ Installed with mise, from a release or from source, the command is `lsf`: `lsf s
14
14
  | `--data <dir>` | `LSF_DATA` | `<theme>/shopify-local` | The store data folder. Without one, the built-in demo store. |
15
15
  | `--preview <name>` | `LSF_PREVIEW` | none | Turns on a Shopify feature preview. Repeat it, or separate names with commas. See [storefront](storefront.md#feature-previews). |
16
16
 
17
+ On Windows, a path written as Git Bash writes it, `/c/Users/me/project`, is the folder it names,
18
+ `C:\Users\me\project`: for every option that takes a path (`--theme`, `--data`, `--app`, the files
19
+ of `fmt`, `--session`, `--input`, ...), and in its environment variable. Git Bash converts such a
20
+ path itself, except when `MSYS_NO_PATHCONV` is set (see [render](#render)) or when lsf is started
21
+ by another program, such as an npm script run by cmd.exe. On another system it is a path like any
22
+ other; when that folder does not exist, the error names the Windows form instead of offering
23
+ `init` with it.
24
+
17
25
  ## Exit codes
18
26
 
19
27
  | Code | Meaning |
@@ -48,7 +56,7 @@ npx local-storefront serve --port 9292 --static --quiet --strict
48
56
  | `--static` | | Reads the theme and the data once. The fastest mode, for tests. |
49
57
  | `--strict` | | Refuses to start when the store data has errors. |
50
58
  | `--quiet`, `-q` | | Does not log requests. |
51
- | `--live-reload` | | Reloads open pages when the theme or the data change. It adds a script to the pages: leave it off for tests. |
59
+ | `--live-reload` | | Reloads open pages when the theme (its `assets/` included) or the data change. It adds a script to the pages: leave it off for tests. Without it, an edit shows at the next load: see [reload as you edit](storefront.md#reload-as-you-edit). |
52
60
  | `--customer <email>` | `LSF_CUSTOMER` | Visitors arrive logged in as this customer. Also `default` (the first customer) or `none`. |
53
61
  | `--throttle <rules>` | `LSF_THROTTLE` | Answers late, to see loading states: `simulated`, `slow`, `300ms`, `cart-add=1s`. See [testing](testing.md#simulating-a-slow-network). |
54
62
  | `--timings` | | Names every section and theme block in the `Server-Timing` header. See [performance](performance.md#render-times). |
@@ -304,6 +312,15 @@ invalid badges: extensions/badges/blocks/stars.liquid: a block needs a {% schem
304
312
  checked 11 of 11 scenarios in 0.1 s: 0 failures, 0 invalid scenarios, 1 extension not offered, 1 stale module, 1 error in theme app extensions
305
313
  ```
306
314
 
315
+ With no scenario, nothing runs, so an extension that is only not built fails nothing: rather
316
+ than a `fails` line, the last line names it with the command that builds it, as it names
317
+ `app init`. It still exits with `1`. What is wrong with the app itself, such as an `api_version`
318
+ lsf does not run, is still printed, and counted after it:
319
+
320
+ ```text
321
+ checked 0 of 0 scenarios in 0.0 s: no scenario to check: `lsf app init` writes a set for the app in `shopify-local/checkouts/`; 2 extensions are not built (payment-rules, order-notice): `lsf app build` builds them (or `--build`)
322
+ ```
323
+
307
324
  A trap ends with the last message the Function logged, such as the one of a `throw`:
308
325
 
309
326
  ```text
@@ -361,7 +378,7 @@ checked 8 of 8 scenarios in 0.1 s: every Function ran, the `expect` of 2 scenari
361
378
  | `--extension <handle>` | | Only these extensions. Repeat it for several. |
362
379
  | `--build` | | Builds the extensions first, as `app build` does. |
363
380
  | `--format text` | | Default. One line per problem, then a summary. |
364
- | `--format json` | | `{ok, state, checked, total, elapsedMs, failures: [{handle, target, error, logs, scenarios}], invalid: [{scenario, file, problems}], expectations: {checked, unchecked: [scenario], unmet: [{scenario, file, step, assertion, expected, actual}]}, ranInNoScenario: [handle], notOffered: [{handle, reason}], stale: [{handle, file}], themeExtensions: [{handle, severity, message}]}`, and `error` when there is no scenario. `state` is `done`, or `partial` when the time ran out. A Function target that fails in several ways has a failure for each error, with the scenarios of that error; `logs` is what the Function logged in the first of them. `expectations.checked` counts the expectations of the scenarios' `expect`, met or not; `unchecked` names the scenarios whose `expect` was not checked, as `--extension` leaves out a Function they run; `assertion` is the list and the rule (`express_methods.excludes`) or the amount (`totals.total`), `expected` what the scenario says (the entry, the list, the amount), `actual` what the checkout has. `ranInNoScenario` is filled only when every scenario ran. `notOffered` has the extensions the checkout does not offer, `stale` the Functions whose `file` changed after their module was built, `themeExtensions` what Shopify rejects (`error`) or advises against (`warning`) in the theme app extensions. |
381
+ | `--format json` | | `{ok, state, checked, total, elapsedMs, failures: [{handle, target, error, logs, scenarios}], invalid: [{scenario, file, problems}], expectations: {checked, unchecked: [scenario], unmet: [{scenario, file, step, assertion, expected, actual}]}, ranInNoScenario: [handle], notOffered: [{handle, reason}], stale: [{handle, file}], themeExtensions: [{handle, severity, message}]}`, and `error` when there is no scenario, which names the extensions not built too, as the last line of the text does (`notOffered` still lists them). `state` is `done`, or `partial` when the time ran out. A Function target that fails in several ways has a failure for each error, with the scenarios of that error; `logs` is what the Function logged in the first of them. `expectations.checked` counts the expectations of the scenarios' `expect`, met or not; `unchecked` names the scenarios whose `expect` was not checked, as `--extension` leaves out a Function they run; `assertion` is the list and the rule (`express_methods.excludes`) or the amount (`totals.total`), `expected` what the scenario says (the entry, the list, the amount), `actual` what the checkout has. `ranInNoScenario` is filled only when every scenario ran. `notOffered` has the extensions the checkout does not offer, `stale` the Functions whose `file` changed after their module was built, `themeExtensions` what Shopify rejects (`error`) or advises against (`warning`) in the theme app extensions. |
365
382
  | `--budget <seconds>` | | How long the Functions may run in all. Default 300. |
366
383
 
367
384
  ### app function run
@@ -544,6 +561,12 @@ sections, as the Section Rendering API does. `/account` (lsf's page of the custo
544
561
  `/recommendations/products` and `/search/suggest` are answered by the server, as `serve`
545
562
  answers them.
546
563
 
564
+ In Git Bash, a path that starts with `/` and has no query string reaches lsf as a Windows path,
565
+ under the folder Git Bash is installed in (`C:/Program Files/Git/`). `render` and `profile` refuse such a path with an error
566
+ (`2`) rather than render the 404 page. Keep the path as typed with
567
+ `MSYS_NO_PATHCONV=1 npx local-storefront render /products/shirt`, or start it with `//`
568
+ (`render //products/shirt`): leading slashes count as one.
569
+
547
570
  The HTML goes to stdout. On stderr: each Liquid error and each warning, then a summary.
548
571
 
549
572
  ```text
@@ -551,6 +574,15 @@ Liquid error (layout/theme line 34): divided by 0
551
574
  status 200 · template product · 1 Liquid error · 0 warnings
552
575
  ```
553
576
 
577
+ A page that renders one of [lsf's default menus](data-format.md#lsfs-default-menus) gets a
578
+ warning for it. With `--format github` it is a `::warning` annotation. It never fails
579
+ `--strict`: a Shopify store cannot delete that menu, so the page is not wrong, only its links
580
+ are lsf's.
581
+
582
+ ```text
583
+ warning: menu "main-menu" is lsf's default (Home, Catalog), not the store's: the store data has none with this handle. Add it to "menus" to render the store's links
584
+ ```
585
+
554
586
  With `--strict`, a path with nothing at it adds `error: nothing at /x: the page is the theme's
555
587
  404`.
556
588
 
@@ -563,7 +595,7 @@ Prints what the render of one page spends its time on: sections, blocks, snippet
563
595
 
564
596
  | Option | What it does |
565
597
  |---|---|
566
- | `<path>` | The path, with its query string. Default `/`. |
598
+ | `<path>` | The path, with its query string. Default `/`. In Git Bash, see [render](#render) for a path Git Bash turned into a Windows one. |
567
599
  | `--points` | Counts in points, what the page would cost Shopify, instead of time. |
568
600
  | `--cost <kind>=<points>,...` | Changes what a kind of thing costs in points. |
569
601
  | `--lines` | Adds every tag and every output, by file and line. |
@@ -584,6 +616,18 @@ app, such as [the checkout playground](https://github.com/0xtlt/local-storefront
584
616
  alone, with the store's own languages; an app without any is checked with the demo store, as its
585
617
  checkout runs. A folder that is neither a theme nor an app, and has no store data, is an error.
586
618
 
619
+ With a theme, it also checks the menus the theme names, in its `link_list` settings (of
620
+ `config/settings_data.json`, of the sections of its JSON templates and section groups, of their
621
+ blocks) and in Liquid (`linklists['main-menu']`). A `main-menu` or `footer` that the data does
622
+ not have is a `default_menu` warning, since [lsf renders its own](data-format.md#lsfs-default-menus).
623
+ Another menu the data does not have is a `missing_menu` warning: lsf renders no menu there.
624
+
625
+ ```text
626
+ warning[default_menu]: the store data has no "main-menu" menu, which the theme names (sections/header-group.json at /sections/header/settings/menu): lsf renders its default in its place (Home, Catalog), not the store's links
627
+ --> (data) at /menus/main-menu
628
+ hint: add the store's "main-menu" to "menus" (or a menus/main-menu.json file); with "links": [] it renders no links
629
+ ```
630
+
587
631
  In a Shopify app (or with `--app`), it checks the app too: what its Functions and UI extensions
588
632
  get wrong (no build, an API version lsf does not run, an unknown target, an `owner_type`), and
589
633
  its theme app extensions as `app build` checks them (`app: badges: extensions/badges/blocks/stars.liquid:
@@ -113,11 +113,13 @@ metaobject type), `product`, `collection`, `page`, `blog`, `article`, `policy`,
113
113
 
114
114
  With their related types: variants, options and option values (with swatches), media (images,
115
115
  videos, external videos, 3D models), image presentation and focal points, collection filters
116
- (with swatches) and sort options, links, addresses, orders and line items, comments, metafields
117
- of every type (a `color` is a color, a measurement has `type`, `value` and `unit`, a `money`
118
- prints as cents, has the `currency` it is written in, and the money filters and `metafield_text`
119
- format it in that currency, an `article_reference` is `blog/article` or the handle of an article
120
- that only one blog has, a `file_reference` is an image, a video or a file by its extension),
116
+ (with swatches) and sort options, links, addresses, orders and line items (a cart line has the
117
+ `index` shopify.dev leaves out: its 0-based position, which Horizon posts plus 1 as the `line` of
118
+ `/cart/change.js`; `/cart.js` has no such field), comments, metafields of every type (a
119
+ `color` is a color, a measurement has `type`, `value` and `unit`, a `money` prints as cents,
120
+ has the `currency` it is written in, and the money filters and `metafield_text` format it in
121
+ that currency, a reference is a GID or the key the data gives the entity
122
+ ([reference values](data-format.md#reference-values)), a `file_reference` is an image, a video or a file by its extension),
121
123
  metaobjects, countries, languages (with `direction`, from the script of the language),
122
124
  currencies, markets, fonts, colors, color schemes, quantity rules and quantity price breaks,
123
125
  unit prices, selling plan groups, selling plans and their allocations, store availabilities and
@@ -132,6 +134,19 @@ locations, companies, company locations and company addresses.
132
134
  defaults from the schema, `disabled` sections and blocks.
133
135
  - Theme blocks (`blocks/*.liquid`), nested blocks, static blocks, `{% content_for 'blocks' %}`
134
136
  and `{% content_for 'block' %}`, `closest.<resource>`.
137
+ - The id of a theme block (`block.id`, and its wrapper's `id="shopify-block-<id>"`) is
138
+ `<prefix>__<key>`, the key being its key in the JSON template or the `id` of a static block.
139
+ A block rendered again on the same page is numbered as on Shopify: the first render is
140
+ `<prefix>__<key>`, the second `<prefix>__<key>-1`, the third `<prefix>__<key>-2`. This
141
+ happens with a static block in a `for` loop (Horizon's product card), the blocks it holds,
142
+ or a second `{% content_for 'blocks' %}`. `section.blocks` gives the ids without a number.
143
+ A section rendered alone (`?section_id=`, `?sections=`) starts again from the first, and a
144
+ section asked for twice in `?sections=` is rendered once. The prefix is not Shopify's: do
145
+ not rely on its value. Shopify gives each block a prefix of its own, local-storefront one
146
+ per section, so two blocks that give a static block the same id (two collection links with
147
+ their `title`) give it the same id, and neither is numbered. A block defined in a section's
148
+ schema keeps its key as its id, and app blocks and blocks called with `{% block %}` are not
149
+ numbered.
135
150
  - With `--preview liquid-july-26`, blocks called from a template or a layout, as in Shopify's
136
151
  Skeleton theme: `{% block 'name', parameter: value %}content{% endblock %}`. The parameters
137
152
  are read when the tag opens. The block sees them and its body, as `content` (the Liquid
@@ -163,7 +178,10 @@ locations, companies, company locations and company addresses.
163
178
  `gid://shopify/Metaobject/<id>`. Dynamic sources are evaluated (`{{ product.title }}`,
164
179
  `{{ closest.product.metafields.custom.x }}`).
165
180
  - Locales: storefront translations and schema translations, pluralization rules by language,
166
- the fallback to the default locale, Shopify's built-in translations.
181
+ the fallback to the default locale, Shopify's built-in translations. The translated content
182
+ of metaobjects, from the store data, on the routes of their language
183
+ ([data format](data-format.md#translations-of-metaobjects)); not yet that of products,
184
+ collections, pages or articles.
167
185
  - Assets: `asset_url` and friends, `.liquid` assets, the `{% stylesheet %}` and
168
186
  `{% javascript %}` bundles, `{% style %}`, preload headers.
169
187
  - With `--app`, theme app extensions: app blocks (`{% render block %}`, `{% content_for 'blocks'
@@ -237,11 +255,20 @@ local-storefront. The rules are the ones read from Shopify's answers in October
237
255
  color functions get a fallback, and vendor prefixes are added.
238
256
  - This applies to the assets, `.liquid` ones included once they are rendered, and to the
239
257
  bundles built from the `{% stylesheet %}` and `{% javascript %}` tags.
258
+ - A declaration is merged into another only when the result sets the same properties: four
259
+ sides into `margin`, for instance. `translate`, `rotate` and `scale` stay apart from
260
+ `transform`, since they are applied before it and cascade on their own, and `font` and
261
+ `transition` stay where they are written, before or after `font-variant-numeric` or
262
+ `transition-behavior`, which they reset.
240
263
  - A minified file ends with a link to its source map, which is served at the path of the
241
- file followed by `.map`: the tools of a browser show the file as it is written.
264
+ file followed by `.map`: the tools of a browser show the file as it is written. The link
265
+ carries the `?v=` the file was asked at (`base.css?v=123` links `base.css.map?v=123`), as
266
+ Shopify's does, so that the map's URL changes with the file.
242
267
  - A file is served as written when its name ends with `.min.js` or `.min.css`, when the
243
- minified file would not be lighter, or when it cannot be read as CSS or JavaScript. In a
244
- stylesheet, a rule that cannot be read is kept as written and the rest is minified.
268
+ minified file, link included, would not be lighter, or when it cannot be read as CSS or
269
+ JavaScript. There is no size limit below which a file is left alone: a file of a few hundred
270
+ bytes is minified when that saves bytes. In a stylesheet, a rule that cannot be read is kept
271
+ as written and the rest is minified.
245
272
 
246
273
  `--no-minify` serves every file as it is written.
247
274
 
@@ -429,19 +456,19 @@ These are deliberate or not done yet. None of them raises a Liquid error.
429
456
  | Customer accounts | New customer accounts are hosted by Shopify, outside the theme. `/account` is a page of local-storefront that stands in for them: it shows who is logged in and lets you become anyone in the data. With `--app`, it is the customer account of the app instead: orders, profile and the pages of its customer account UI extensions ([checkout](checkout.md#customer-account)). Every account URL (`/account/login`, `/account/register`, `/customer_authentication/login`, ...) leads to it. Classic customer accounts are not simulated: the theme's `templates/customers` are never rendered, and its account forms (login, register, password reset, addresses) lead to `/account`. |
430
457
  | Password page | `/password` always shows the theme's password page, and accepts `shop.password` (`password` by default). The storefront is never locked: nothing redirects to that page. On Shopify, a protected store redirects every page to it, and an open store redirects it to the home page. |
431
458
  | Discounts | Carts and orders have no discounts: discount arrays are empty and totals are undiscounted (the checkout of an app applies the discounts its Functions give, nowhere else). `Shopify.actions.updateCart` reports every discount code as not applicable. |
432
- | Standard actions | A change the cart refuses (unknown variant, not enough stock) resolves with `userErrors` whose code is `INVALID`, and leaves that line untouched. The Storefront API would add what is in stock and return a warning. |
459
+ | Standard actions | A change the cart refuses (unknown variant) resolves with `userErrors` whose code is `INVALID`, and leaves that line untouched. More than the stock resolves with the same `userErrors`, the line holding what is in stock: the Storefront API would return a warning instead. |
433
460
  | Selling plans | Selling plan groups, plans and allocations are there, on products, variants, the cart and `/products/<handle>.js`. A group applies to every variant of a product, not to some of them. Prepaid plans are not modelled: `per_delivery_price` is the price. The line items of orders have no selling plan. The widget of a subscription app is an app block: it renders with `--app` when it is that app's, else the theme's own purchase options do. |
434
461
  | Store pickup | `variant.store_availabilities` and `/variants/<id>?section_id=` are there. Locations come in the order of the data: Shopify sorts them by distance from the visitor. Choosing pickup happens at checkout, which is not part of a theme, so `order.pickup_in_store?` is always `false`. |
435
462
  | Swatches | A swatch is given to option values by name, or to one value of a product. On Shopify it comes from the category metafield the option is linked to. `filter_value.image` (the `image` presentation of a filter) is not generated. |
436
463
  | B2B | Companies, company locations, their addresses and metafields are there: `customer.b2b?`, `current_company`, `current_location`, `company_available_locations` and the link that changes location. Catalogs are not: a B2B customer sees the same products, prices and quantity rules as everyone. No payment terms, no store credit. |
437
- | Markets | One price list: every country sees the shop currency's prices. The selected country changes `localization.country`, not prices. |
464
+ | Markets | A country pays in its currency (its own, its market's, else the shop's), at its market's `rate` (1 when there is none: lsf has no exchange rates) or at its fixed `prices`, on the storefront and at the checkout. Shopify's rounding rules of converted prices (`.99`, `.95`) and price adjustments in percent are not applied. A market is served at `/` or under subfolders of its own (`/en-ca`); a market with a domain of its own is not served. Orders the checkout records in the Admin API keep the amounts of the checkout, without converting them back to the shop's currency. |
438
465
  | Taxes and shipping | Not computed. |
439
466
  | Search | A simple search over titles, descriptions, vendors, types, tags and SKUs. Ranking is not Shopify's: `relevance` keeps the order of the data. `sort_by` takes `price-ascending` and `price-descending` (any other value is `relevance`); pages and articles come after the products. |
440
467
  | Recommendations | Products of the same collections, or the product's explicit `recommendations`. |
441
468
  | Collection filters | Availability, price, vendor, product type, tag and variant options, with the swatches of option values. Metafield filters are not generated. |
442
469
  | Placeholder artwork | `image`, `collection-2` and `lifestyle-2` are Shopify's illustrations: the three that are published under a free license. For the other names, `placeholder_svg_tag` draws generic artwork of the right proportions. |
443
470
  | Fonts | Shopify's font library is not bundled. `font_face` and `font_url` generate the same markup and URLs; the files are blank unless provided in `files/fonts/`. |
444
- | Shopify's shared assets | Requests under `/cdn/shopifycloud/` (payment buttons, model viewer UI, ...) get an empty file of the right type. The files of `shopify_asset_url` are served from the binary: `vendor/qrcode.js` is the qrcode.js library (MIT), as on Shopify; `shopify_common.js` has our own `Shopify.formatMoney` and `Shopify.resizeImage`; `option_selection.js`, `api.jquery.js`, `customer_area.js` and `currencies.js` only log a warning to the console, and `customer.css` is empty, because Shopify's code is proprietary; `gift-card/card.svg` is a drawing of our own, of the size of Shopify's, and `gift-card/add-to-apple-wallet.svg` a placeholder drawing. Any other name is a 404, logged by the server. |
471
+ | Shopify's shared assets | Requests under `/cdn/shopifycloud/` (payment buttons, model viewer UI, ...) get an empty file of the right type. The files of `shopify_asset_url` are served from the binary: `vendor/qrcode.js` is the qrcode.js library (MIT), as on Shopify; `shopify_common.js` has our own `Shopify.formatMoney`, `Shopify.resizeImage` and `Shopify.CountryProvinceSelector` (the province selector of an address form, filled from `data-provinces`); `option_selection.js`, `api.jquery.js`, `customer_area.js` and `currencies.js` only log a warning to the console, and `customer.css` is empty, because Shopify's code is proprietary; `gift-card/card.svg` is a drawing of our own, of the size of Shopify's, and `gift-card/add-to-apple-wallet.svg` a placeholder drawing. Any other name is a 404, logged by the server. |
445
472
  | Theme editor | `request.design_mode` is always `false`, `Shopify.designMode` is not set, and `block.shopify_attributes` is empty. |
446
473
  | Integers | 64-bit. Liquid on Shopify uses arbitrary-precision integers. |
447
474
  | Byte strings | Strings are text (UTF-8). `base64_decode` of bytes that are not text replaces them with `�`, where Shopify carries the bytes on to the next filter. |
@@ -450,7 +477,7 @@ These are deliberate or not done yet. None of them raises a Liquid error.
450
477
  | Caching | Shopify keeps rendered pages in a cache of its own. An answer from that cache has an `ETag` and no `Cache-Control`; local-storefront always answers like a page that was just rendered. Files have no `Last-Modified`, so nothing is answered with `304 Not Modified`. |
451
478
  | Image formats | The format of an image is chosen by Shopify's rule, with other encoders: the files weigh about what Shopify's do, not the same, so an image can come as AVIF where Shopify sends WebP, or the reverse. A GIF is never converted. The color profile and the metadata of a file are not kept when it is encoded. |
452
479
  | Preloading | Shopify sends the `Link` header ahead of the page, as `103 Early Hints`, and with the pages it answers from its own cache. local-storefront sends it with every page. Shopify's header also asks to connect to its CDN (`cdn.shopify.com`), which serves nothing locally, and has a limit on its number of entries, which local-storefront does not have. |
453
- | Minification | Shopify minifies with esbuild, local-storefront with other libraries (oxc for scripts, Lightning CSS for stylesheets): a file weighs about what it does on Shopify, without being the same to the byte. In a stylesheet, declarations can come in another order within a rule. `inset` is not replaced with `top`, `right`, `bottom` and `left`. |
480
+ | Minification | Shopify minifies with esbuild, local-storefront with other libraries (oxc for scripts, Lightning CSS for stylesheets): a file weighs about what it does on Shopify, without being the same to the byte. In a stylesheet, declarations can come in another order within a rule. `inset` is not replaced with `top`, `right`, `bottom` and `left`, and the width, style and color of the four sides of a border, all set in one rule, become `border`, which also resets `border-image`. Shopify's link to the source map carries the `?v=` of the request, local-storefront's does not: a file that minifying saves only a few dozen bytes of can be minified locally and served as written on Shopify. |
454
481
  | Compiled scripts | Shopify runs the `{% javascript %}` of a section only on the pages that have that section. local-storefront runs them all on every page. |
455
482
 
456
483
  Found a difference that is not in this table? It is a bug: a Liquid snippet and the HTML
@@ -52,6 +52,7 @@ backend's tests use, then the rest. How a test uses them:
52
52
  | `GET /checkout?scenario=<name>` | Opens a checkout for a scenario, in a new session whose cookie it sets. With `--app`. | A redirect |
53
53
  | `GET /__lsf/checkout/assets/<file>` | The files of the checkout page, which are in the binary. With `--app`. | The file |
54
54
  | `GET /__lsf/extensions/<handle>/<file>` | A file of the build (`dist/`) of a checkout UI extension. With `--app`. | The file |
55
+ | `POST /__lsf/extensions/<handle>/session-token` | What `shopify.sessionToken.get()` gives a checkout or customer account UI extension of the app: a token for the shop (`dest`, its bare domain), the app (`aud`) and the customer logged in in the caller's session (`sub`, absent logged out), valid five minutes, signed with HS256 and the client secret ([session tokens](checkout.md#session-tokens)). The checkout page and the customer account ask it for their extensions; a test can call the backend with it. No CORS: another site cannot read it. `404` for an extension the app does not serve. With `--app`. | `{ "token", "authorization", "shop", "claims" }` |
55
56
  | `GET /__lsf/admin` | The [Admin API](admin.md): its shops, their tokens, record counts and webhook subscriptions, the app, the throttle, the mutations, each shop's outbox. | JSON, [below](#admin-api) |
56
57
  | `GET /__lsf/graphiql` | GraphiQL on the [Admin API](admin.md#graphiql-in-the-browser): the shops, their tokens and the served versions filled in. | HTML |
57
58
  | `DELETE /__lsf/admin` | Puts every shop of the Admin API back to the store data (its clock and its outbox too), forgets the subscriptions the backend made and the tokens OAuth issued, and installs the app again (the printed token works). It revokes every token of the app, those of token exchange too, and sends no `app/uninstalled`: the backend's stored tokens are answered `401` ([reset between runs](admin.md#reset-between-runs)). | `{ "ok": true, "shops": [...] }` |
@@ -128,7 +129,7 @@ curl -H 'x-lsf-session: my-test' http://127.0.0.1:9292/cart.js
128
129
  | `customer` | An email, `"default"` (the first customer of the data) or `"none"`. |
129
130
  | `company_location` | For a B2B customer, the location they buy for. |
130
131
  | `cart` | `items`, `note`, `attributes`. Each item has a `variant` (a SKU, an id, or a product handle for its first variant), and optionally a `quantity`, `properties` and a `selling_plan`. See [Cart](data-reference.md#cart). |
131
- | `country` | The country the visitor shops from, by ISO code. |
132
+ | `country` | The country the visitor shops from, by ISO code. A country whose market is served under subfolders of its own (`subfolder_suffix`) is the visitor's on the pages under them, not at `/`, as on Shopify: see [countries and markets in the URL](storefront.md#countries-and-markets-in-the-url). |
132
133
  | `data` | Store data for this session only, applied over the files and over what the [Admin API](admin.md#what-the-storefront-and-the-checkout-see) changed. See [testing](testing.md#data-for-one-test-only). |
133
134
  | `throttle` | Delays for this session only. See [testing](testing.md#simulating-a-slow-network). |
134
135
 
@@ -120,7 +120,8 @@ all, and a running server serves them at `/__lsf/schema/<kind>`.
120
120
  renders are reproducible.
121
121
  - **References** between entities use handles (products, collections, pages, blogs), emails
122
122
  (customers) and SKUs or ids (variants). A reference to something that does not exist is an
123
- error, with a suggestion when a close match exists.
123
+ error, with a suggestion when a close match exists. A metafield also takes Shopify's GIDs
124
+ ([reference values](#reference-values)).
124
125
  - **HTML** fields (`description`, `content`, `body`) are inserted as written.
125
126
 
126
127
  ## Products
@@ -163,7 +164,8 @@ product with options lists its variants; each gives one value per option:
163
164
  `"inventory_policy": "continue"`. A variant without `inventory_quantity` is not tracked and
164
165
  always available. `available` forces the answer either way. `"available": false` alone also
165
166
  makes the variant tracked with a quantity of 0, as a sold-out variant is on Shopify, so themes
166
- show "Sold out". Adding more than the stock to the cart fails as it does on Shopify.
167
+ show "Sold out". Asking the cart for more than the stock (`/cart/add.js`, `/cart/change.js`)
168
+ puts what is in stock in it and answers `422`, as on Shopify.
167
169
  `"available": false` with an `inventory_quantity` above 0 is a warning
168
170
  (`unavailable_in_stock`): Shopify never has both. lsf keeps the variant unavailable with its
169
171
  stock, which Horizon shows as "Unavailable".
@@ -396,6 +398,42 @@ resource with one of `url`, `collection`, `product`, `page`, `blog`, `article`
396
398
  }
397
399
  ```
398
400
 
401
+ ### lsf's default menus
402
+
403
+ Shopify's default menus, `main-menu` and `footer`, cannot be deleted from a store, and a theme's
404
+ `link_list` setting can only default to one of them. When the data does not have one of them,
405
+ lsf makes it up with links of its own:
406
+
407
+ | Handle | Title | Links |
408
+ |---|---|---|
409
+ | `main-menu` | Main menu | Home (`/`), Catalog (`/collections/all`) |
410
+ | `footer` | Footer menu | Search (`/search`) |
411
+
412
+ A header that shows these links does not prove that the data's menus work: they are lsf's, not
413
+ the store's. lsf says so everywhere it can:
414
+
415
+ - `lsf validate` warns (`default_menu`) for each of the two that the theme names, in a
416
+ `link_list` setting or in Liquid (`linklists['main-menu']`), and says where.
417
+ - `lsf render` prints a warning for each one the page renders. It is not an error, even with
418
+ `--strict`.
419
+ - With `serve --live-reload`, a page that renders one shows a note in its panel, and the
420
+ terminal says it once.
421
+
422
+ There is no option to turn them off, since a Shopify store always has them. Give the data the
423
+ store's menu instead. To render it without links, as a store whose menu is empty, write it with
424
+ none:
425
+
426
+ ```json
427
+ { "menus": { "footer": { "links": [] } } }
428
+ ```
429
+
430
+ Any other handle the data does not have is not made up. In lsf, `linklists['customer-care']` is
431
+ then `nil`, and so is a `link_list` setting that names it: both equal `blank`, and a loop over
432
+ their `links` renders nothing. Shopify documents that a `link_list` setting whose menu does not
433
+ exist is `blank`. Its reference does not say what `linklists` gives for a handle the store does
434
+ not have. `lsf validate` warns about each menu the theme names that the data does not have
435
+ (`missing_menu`).
436
+
399
437
  ## The shop
400
438
 
401
439
  Every field of `shop` is optional.
@@ -721,13 +759,26 @@ store has none: `npx local-storefront app init` writes a set.
721
759
 
722
760
  The first language is the primary one, served at `/`. The others are served under
723
761
  `/<iso_code>/` (`/fr/products/blue-shirt`) and use the theme's `locales/<iso_code>.json`.
724
- Names and currencies of countries default to built-in values. Without `countries`, the store
762
+ Names of countries default to built-in values. Without `countries`, the store
725
763
  sells in one country: the shop's (`shop.address.country_code`), or `US`. The localization form
726
764
  (`{% form 'localization' %}`) switches the session's country and language. A visitor who
727
765
  selected no country is in the shop's (`shop.address.country_code`), the one of its primary
728
766
  market, on the storefront as at the checkout; in the first country when the store does not sell
729
767
  to the shop's.
730
768
 
769
+ A country has the provinces Shopify lists for it (the states of the United States, the provinces
770
+ of Canada, the prefectures of Japan, the regions of the United Kingdom: 37 countries, from
771
+ Shopify's address data). The country selectors of a theme list them as Shopify does: in
772
+ `data-provinces` of `country_option_tags` and `all_country_option_tags`, sorted by name, each a
773
+ pair of the name a form sends and the name to show (`["Tōkyō","Tokyo"]`,
774
+ `["District of Columbia","Washington DC"]`). The province selector of the checkout lists them
775
+ too, and an address gets the name of its province from its code, or the code from the name.
776
+ `provinces` restricts a country to those the store ships to, by code:
777
+
778
+ ```json
779
+ { "iso_code": "US", "provinces": ["CA", "OR", "WA"] }
780
+ ```
781
+
731
782
  `markets` groups countries the way Shopify's markets do. The checkout Functions read the
732
783
  market of the buyer (`localization.market`) and of an address (`MailingAddress.market`):
733
784
 
@@ -735,8 +786,14 @@ market of the buyer (`localization.market`) and of an address (`MailingAddress.m
735
786
  {
736
787
  "localization": {
737
788
  "countries": [{ "iso_code": "FR" }, { "iso_code": "DE" }, { "iso_code": "US" }],
789
+ "languages": [{ "iso_code": "en" }, { "iso_code": "fr" }, { "iso_code": "de" }],
738
790
  "markets": [
739
- { "handle": "europe", "countries": ["FR", "DE"], "metafields": { "custom": { "tier": "gold" } } }
791
+ {
792
+ "handle": "europe",
793
+ "countries": ["FR", "DE"],
794
+ "languages": ["fr", "de"],
795
+ "metafields": { "custom": { "tier": "gold" } }
796
+ }
740
797
  ]
741
798
  }
742
799
  }
@@ -745,6 +802,55 @@ market of the buyer (`localization.market`) and of an address (`MailingAddress.m
745
802
  A country in no market is a market of its own, named after the country (`france`, `united-states`).
746
803
  An address in a country the store does not sell to has no market.
747
804
 
805
+ A market's `languages` are the languages its countries are offered, as on Shopify:
806
+ `country.available_languages` lists them in that order, and so does
807
+ `localization.available_languages` for the visitor's country. The first is the market's default:
808
+ when the localization form switches to a country whose market does not offer the current
809
+ language, the visitor moves to that default; a language it offers stays. Here France and Germany
810
+ get French then German (a visitor on an English page who switches to Germany lands on `/fr/...`),
811
+ and the United States every language. A market without `languages`, and a country in no market,
812
+ offer every language of the store, the primary first. A language that is not in
813
+ `localization.languages` is an `unknown_language` error. Every language is served under
814
+ `/<iso_code>/` at `/`, whatever the visitor's country; a market with a `subfolder_suffix` (below)
815
+ has URLs of its own, in its languages only.
816
+
817
+ A market can sell in its own currency, as a Shopify market with a base currency: `currency`, the
818
+ `rate` that converts the store's prices to it (what one unit of the store currency is worth, as a
819
+ manual exchange rate; 1 when there is none, since lsf has no exchange rates), and `prices`, fixed
820
+ prices of a price list, which are not converted. A key of `prices` is a SKU, a variant id, or a
821
+ product handle for each of its variants; a value a price, or `{"price", "compare_at_price"}`.
822
+
823
+ ```json
824
+ {
825
+ "handle": "europe",
826
+ "countries": ["FR", "DE"],
827
+ "currency": "EUR",
828
+ "rate": "0.92",
829
+ "prices": { "TEE-WHT-S": "25.00", "canvas-tote-bag": { "price": "30.00", "compare_at_price": "40.00" } }
830
+ }
831
+ ```
832
+
833
+ A country pays in its own `currency`, else in its market's, else in the store's. A visitor in it
834
+ sees every price in that currency, rounded to its minor unit (to whole yen for JPY): products,
835
+ variants, compare-at and unit prices, selling plans, the cart, `/cart.js`, the checkout and its
836
+ shipping rates. `cart.currency`, `localization.country.currency` and the money filters follow;
837
+ `shop.currency` stays the store's, and `shop.enabled_currencies` lists every currency of the
838
+ countries. Gift cards keep their own currency, and orders of the data are priced as their
839
+ variants are.
840
+
841
+ `subfolder_suffix` serves a market under subfolders of its own, as a Shopify market whose web
842
+ presence is a subfolder of the primary domain: `/<language>-<suffix>/` for each of its
843
+ `languages` (`/en-be/`, `/fr-be/`), or each language of the store when it lists none. A subfolder
844
+ in a language the market does not offer is not found, as on Shopify (`/es-be/` when Belgium
845
+ offers English and French). A page there is in a country of the market, and `?country=` and the
846
+ localization form move the visitor between `/` and the subfolders as Shopify does, in a language
847
+ the market offers (see [the storefront guide](storefront.md#countries-and-markets-in-the-url)).
848
+ The market of the shop's country is served at `/`, so it cannot have one.
849
+
850
+ ```json
851
+ { "handle": "belgium", "countries": ["BE"], "subfolder_suffix": "be" }
852
+ ```
853
+
748
854
  ## Images and files
749
855
 
750
856
  An image is a path inside `files/`, optionally with metadata:
@@ -789,8 +895,8 @@ is served so that browsers fall back to the theme's fallback stack without netwo
789
895
  ## Metafields and metaobjects
790
896
 
791
897
  Metafields are grouped by namespace, then key. A plain value gets its type inferred; spell out
792
- `{ "type", "value" }` for anything else. References are written as handles, files as paths
793
- inside `files/`.
898
+ `{ "type", "value" }` for anything else. References are written as handles or GIDs
899
+ ([reference values](#reference-values)), files as paths inside `files/`.
794
900
 
795
901
  ```json
796
902
  {
@@ -812,16 +918,30 @@ inside `files/`.
812
918
  `{{ product.metafields.custom.size_guide.value.title }}` then behave as on Shopify, including
813
919
  `metafield_tag` and `metafield_text`.
814
920
 
921
+ A value that is an object or a list (a `rating`, a `money`, a `weight`, a `list.` type...) may
922
+ also be written as its JSON text, the Admin API's `value`, so that it can be pasted from the API
923
+ as is: `"{\"scale_min\":\"1.0\",\"scale_max\":\"5.0\",\"value\":\"4.5\"}"`. A `json`
924
+ metafield is the exception: a string is a value of it.
925
+
926
+ A `rating` keeps its `value`, `scale_min` and `scale_max` as strings, as Shopify does:
927
+
928
+ ```json
929
+ "rating": { "type": "rating", "value": { "scale_min": "1.0", "scale_max": "5.0", "value": "4.5" } }
930
+ ```
931
+
932
+ Its Liquid object is Shopify's, with strings too: `{{ m.value | json }}` gives
933
+ `{"rating":"4.5","scale_max":"5.0","scale_min":"1.0"}`, and `{% for i in (1..m.value.scale_max) %}`
934
+ runs 5 times, as Liquid turns the string `"5.0"` into 5 (it raises "invalid integer" on the
935
+ number `5.0`). A number in the data is kept as it is written, as a string: `"scale_max": 5` is
936
+ `"5"`.
937
+
815
938
  A typed value is checked against its type, as Shopify checks it. `{"type": "number_integer",
816
939
  "value": "two"}` is an `invalid_metafield` error, and so is a `weight` in `"kgs"` or a
817
940
  `rating` outside its scale. A `weight`, `dimension` or `volume` takes the units Shopify lists
818
941
  (`kilograms`, `centimeters`, `milliliters`...) and their short forms (`kg`, `cm`, `ml`). A
819
942
  `language` is a locale code (`fr`, `pt-BR`). A type Shopify does not have is an
820
- `unknown_metafield_type`. A
821
- reference that names nothing of the data is an `unknown_reference`, with the closest name:
822
- products, variants (by SKU or id), collections, pages, articles (`blog/article`) and
823
- metaobjects (`type/handle`). A `file_reference` to a file that is not in `files/` is no error:
824
- it shows as a placeholder.
943
+ `unknown_metafield_type`. A reference that names nothing of the data is an
944
+ `unknown_reference` ([reference values](#reference-values)).
825
945
 
826
946
  A `file_reference` is an image when its extension is one (`.jpg`, `.png`, `.webp`...) and a
827
947
  video for `.mp4`, `.mov`, `.webm` and `.m4v`: `sources`, `preview_image`, `video_tag` and
@@ -858,6 +978,98 @@ namespaces are (`$app:config`). Shopify writes them in full, `app--<app id>--aut
858
978
  theme's Liquid uses: `metaobjects['app--1234--author']` and `metaobjects['$app:author']` both
859
979
  find them, whatever the id, since the store data does not know it.
860
980
 
981
+ ### Translations of metaobjects
982
+
983
+ An entry's `translations` give its fields in the store's other languages, by language, then
984
+ field key, as Shopify's translatable resources do (`translationsRegister`):
985
+
986
+ ```json
987
+ {
988
+ "localization": { "languages": [{ "iso_code": "en" }, { "iso_code": "fr" }] },
989
+ "metaobjects": {
990
+ "faq": [{
991
+ "handle": "shipping",
992
+ "fields": {
993
+ "question": "How long does shipping take?",
994
+ "answer": { "type": "rich_text_field", "value": { "type": "root", "children": [{ "type": "paragraph", "children": [{ "type": "text", "value": "Two days." }] }] } },
995
+ "position": 1
996
+ },
997
+ "translations": {
998
+ "fr": {
999
+ "question": "Combien de temps prend la livraison ?",
1000
+ "answer": { "type": "root", "children": [{ "type": "paragraph", "children": [{ "type": "text", "value": "Deux jours." }] }] }
1001
+ }
1002
+ }
1003
+ }]
1004
+ }
1005
+ }
1006
+ ```
1007
+
1008
+ On a route of that language (`/fr/...`), `{{ metaobjects.faq.shipping.question }}`, the
1009
+ entry's page and its title, and the entries a metafield or a setting points to show the French
1010
+ values. As on Shopify, field by field, a field without a translation into the route's
1011
+ language shows its translation into the base language of a regional one (`fr` for `fr-CA`),
1012
+ else its own value.
1013
+
1014
+ - A translation is written as the field's value is (a string, a rich text object, a list), and
1015
+ checked against the field's type: a `url` that is not a URL is an `invalid_metafield`.
1016
+ - Only the types Shopify translates take one: `single_line_text_field`,
1017
+ `multi_line_text_field`, `rich_text_field`, `json`, `link`, `url`, `file_reference`, and the
1018
+ lists of single line texts, links, URLs and files. Numbers, booleans, dates, measurements,
1019
+ money and the other references are the same in every language: translating one is an
1020
+ `untranslatable_field`.
1021
+ - The language is one of `localization.languages` other than the first, the primary one, which
1022
+ the fields are written in (`unknown_language`). A key the entry has no field for is an
1023
+ `unknown_field`, with the closest one. A blank translation is an `invalid_value`: leave the key
1024
+ out instead.
1025
+
1026
+ The [Admin API](admin.md) reads these translations (`translatableResource`) and changes them
1027
+ (`translationsRegister`, `translationsRemove`), and the storefront shows what it changed.
1028
+
1029
+ ### Reference values
1030
+
1031
+ Shopify writes a reference as a GID, and a list of references as a JSON array of GIDs
1032
+ (`["gid://shopify/Metaobject/123", "gid://shopify/Metaobject/456"]`, [list of data
1033
+ types](https://shopify.dev/docs/apps/build/custom-data/metafields/list-of-data-types)). The
1034
+ store data takes that form, and a shorter one: the key the data gives the entity. The shorter
1035
+ form is an lsf convention for fixtures; Shopify does not take it.
1036
+
1037
+ | Type | lsf's form | Shopify's form |
1038
+ |---|---|---|
1039
+ | `product_reference` | the handle: `"ceramic-mug"` | `"gid://shopify/Product/<id>"` |
1040
+ | `variant_reference` | the SKU (`"MUG-1"`), or the id as a number | `"gid://shopify/ProductVariant/<id>"` |
1041
+ | `collection_reference` | the handle | `"gid://shopify/Collection/<id>"` |
1042
+ | `page_reference` | the handle | `"gid://shopify/Page/<id>"` |
1043
+ | `article_reference` | `"blog/article"`, or the article's handle alone when one blog only has it | `"gid://shopify/Article/<id>"` |
1044
+ | `metaobject_reference`, `mixed_reference` | `"type/handle"`: `"faq/size"` | `"gid://shopify/Metaobject/<id>"` |
1045
+ | `file_reference` | the path inside `files/`: `"lookbook.jpg"` | not read: lsf gives files no GID |
1046
+
1047
+ A GID names the entity of that id in the store: the `id` the data gives it, or the one lsf gives
1048
+ it otherwise, which Liquid shows (`product.id`, `metaobject.system.id`) and the [Admin
1049
+ API](admin.md) answers. A metaobject entry has no `id` in the data: lsf makes one from its type
1050
+ and handle, so the GID of an entry copied from a Shopify store names nothing here. Write
1051
+ `"type/handle"` instead.
1052
+
1053
+ Any other form reads `nil` in Liquid and is an error. `"faq.size"`, `"faq:size"`, `"size"` and
1054
+ `{"type": "faq", "handle": "size"}` are neither Shopify's form nor lsf's, and `validate` says
1055
+ what to write:
1056
+
1057
+ ```text
1058
+ error[unknown_reference]: "faq.size" names nothing: a metaobject_reference names a metaobject entry as "type/handle"
1059
+ --> store.json at /products/0/metafields/custom/faqs/value/1
1060
+ hint: use "faq/size"
1061
+ ```
1062
+
1063
+ A reference to something the data does not have is an `unknown_reference` too, with the closest
1064
+ name. These read `nil` without an error, as on Shopify:
1065
+
1066
+ - a product that is not on the storefront (a draft, an unpublished one);
1067
+ - a GID in the [Admin API's changes](admin.md#what-the-storefront-and-the-checkout-see) that
1068
+ names what the Admin API deleted.
1069
+
1070
+ A `file_reference` to a file that is not in `files/` is no error either: it shows as a
1071
+ placeholder.
1072
+
861
1073
 
862
1074
  ## Time and theme settings
863
1075
 
@@ -900,7 +1112,7 @@ extensions:
900
1112
  |---|---|
901
1113
  | `invalid_json` | The file is not valid JSON. The hint gives the line and column. |
902
1114
  | `unreadable_file` | The file could not be read. |
903
- | `unknown_field` | A field that does not exist. The hint suggests the closest one. The field is left out, the rest of the file is read. |
1115
+ | `unknown_field` | A field that does not exist, or a metaobject's translation of a field the entry does not have. The hint suggests the closest one. The field is left out, the rest of the file is read. |
904
1116
  | `classic_customer_accounts` | A warning: `shop.customer_accounts` or a customer's `password`, which classic customer accounts had. The field is ignored. |
905
1117
  | `missing_field` | A required field is absent. |
906
1118
  | `wrong_type` | A value of the wrong JSON type. |
@@ -908,15 +1120,19 @@ extensions:
908
1120
  | `invalid_money`, `invalid_date`, `invalid_email`, `invalid_handle`, `invalid_timezone`, `invalid_gift_card_code` | A value that does not parse as what it should be. `invalid_money` is a shipping rate below zero. |
909
1121
  | `duplicate_handle`, `duplicate_id`, `duplicate_email`, `duplicate_menu`, `duplicate_variant`, `duplicate_gift_card` | Two entities with the same identity. |
910
1122
  | `duplicate_section` | `shop`, `localization`, `session`, `checkout` or `now` defined in two files. |
911
- | `unknown_language` | A checkout scenario names a language that is not in `localization.languages`. |
912
- | `unknown_product`, `unknown_collection`, `unknown_variant`, `unknown_customer`, `unknown_company`, `unknown_location`, `unknown_country`, `unknown_image`, `unknown_reference` | A reference to something that is not in the data, in a file of `checkouts/` as anywhere else. The hint suggests the closest match. A link of a menu is an `unknown_reference`, whatever it names (a product, a collection, a page, an article, a policy), and so is a reference of a metafield, and a shipping rate or a payment method a scenario's `entered` names. |
1123
+ | `unknown_language` | A checkout scenario or a market names a language that is not in `localization.languages`, or a metaobject's `translations` do, or name the primary language. |
1124
+ | `unknown_product`, `unknown_collection`, `unknown_variant`, `unknown_customer`, `unknown_company`, `unknown_location`, `unknown_country`, `unknown_image`, `unknown_reference` | A reference to something that is not in the data, in a file of `checkouts/` as anywhere else. The hint suggests the closest match. A link of a menu is an `unknown_reference`, whatever it names (a product, a collection, a page, an article, a policy), and so is a reference of a metafield (in a form lsf does not read, its hint is the form to write: `use "faq/size"`), and a shipping rate or a payment method a scenario's `entered` names. |
913
1125
  | `duplicate_company`, `duplicate_location`, `missing_locations`, `missing_company` | A company without a location, two companies or two locations with the same name, or a customer that lists locations or a `company_contact` without a company. |
914
1126
  | `duplicate_market_country` | A country in two `localization.markets`. |
1127
+ | `invalid_value`, `invalid_format` of a market's `currency` or `rate` | A currency that is not three letters, a rate that is not a decimal above 0. A key of its `prices` that names no variant is an `unknown_variant`. |
1128
+ | `invalid_value` of a `subfolder_suffix` | A suffix that is not two letters at least, that is a script code (`latn`), or that the market of the shop's country has. Two markets with the same suffix are a `duplicate_handle`. |
1129
+ | `unknown_province` | A code of a country's `provinces` that is not one of its provinces, or `provinces` for a country that has none. The hint lists its codes. |
915
1130
  | `unknown_return_line` | A return of an order names a variant that is not a line of the order. |
916
1131
  | `expect_checks_nothing` (warning) | An `excludes` of a scenario's `expect` names a payment method, express button or delivery option the checkout does not offer, so it checks nothing. The hint lists what it offers. |
917
1132
  | `invalid_throttle` | The `throttle` of a session sent to `PUT /__lsf/session` is not one `--throttle` takes. |
918
1133
  | `unknown_selling_plan`, `missing_selling_plan`, `duplicate_selling_plan` | A selling plan or a group that is not in the data, or that the product is not sold with; a group without a plan, or a product that requires a plan and has none; two groups or two plans with the same name. |
919
- | `invalid_metafield`, `unknown_metafield_type` | A typed metafield (or field of a metaobject) whose value its type refuses, or whose type Shopify does not have. The hint shows how the type is written. |
1134
+ | `invalid_metafield`, `unknown_metafield_type` | A typed metafield (or field of a metaobject, or a translation of one) whose value its type refuses, or whose type Shopify does not have. The hint shows how the type is written. |
1135
+ | `untranslatable_field` | A metaobject's `translations` translate a field of a type Shopify does not translate (a number, a boolean, a product reference...). The hint lists the types it translates. |
920
1136
  | `unknown_setting`, `invalid_setting` | A `theme_settings` id that `config/settings_schema.json` does not have, or a value its setting does not take. The hint suggests the closest id or option. |
921
1137
  | `unchecked_theme_settings` (warning) | The data has `theme_settings` and there is no theme to check them against. |
922
1138
  | `invalid_color`, `empty_swatch` | A swatch whose color is not a CSS color, or that has neither a color nor an image. |
@@ -924,6 +1140,8 @@ extensions:
924
1140
  | `missing_variants`, `missing_options`, `option_mismatch`, `too_many_options` | The options of a product and the option values of its variants do not line up. |
925
1141
  | `media_conflict`, `missing_sources`, `missing_external_video` | Incomplete or contradictory product media. |
926
1142
  | `ambiguous_link` | A menu link that points at more than one thing. |
1143
+ | `default_menu` (warning) | The theme names `main-menu` or `footer`, and the data does not have it: lsf renders [its default](#lsfs-default-menus) there. `file` is `(data)` and `path` is `/menus/<handle>`. |
1144
+ | `missing_menu` (warning) | The theme names another menu that the data does not have: lsf renders no menu there. The message says where the theme names it, and the hint suggests the closest handle. |
927
1145
  | `missing_locale_file` (warning) | A language the theme has no `locales/<code>.json` for. |
928
1146
  | `gift_card_balance` (warning) | A gift card whose balance is above its initial value. |
929
1147
  | `unavailable_in_stock` (warning) | A variant with `"available": false` and an `inventory_quantity` above 0, which Shopify never has. It stays unavailable, with its stock. The hint says which field to remove. |
@@ -170,7 +170,7 @@ A metafield with an explicit type.
170
170
  | Field | Type | Description |
171
171
  |---|---|---|
172
172
  | `type` | string | **Required.** A Shopify metafield type, e.g. `single_line_text_field`, `multi_line_text_field`, `rich_text_field`, `number_integer`, `number_decimal`, `boolean`, `color`, `date`, `date_time`, `url`, `json`, `money`, `rating`, `weight`, `volume`, `dimension` and the other measurements (`distance`, `temperature`, `duration`...), `product_reference`, `collection_reference`, `page_reference`, `article_reference` (`blog/article`, or the article's handle alone), `file_reference`, `metaobject_reference`, or a `list.` variant of those. |
173
- | `value` | any JSON value | **Required.** The value. References are written as handles (`"blue-shirt"`), files as paths inside `files/`, lists as JSON arrays. |
173
+ | `value` | any JSON value | **Required.** The value. Lists are JSON arrays. A reference is written as Shopify writes it, a GID (`"gid://shopify/Product/<id>"`, the id the entity has in the store), or by the key the store data gives the entity, an lsf convention: a handle (`"blue-shirt"`), `blog/article`, `type/handle` of a metaobject entry (`"faq/size"`), a SKU or an id of a variant. Files are paths inside `files/`. An object or a list may also be written as its JSON text, as the Admin API's `value` gives it. |
174
174
 
175
175
  ## Product
176
176
 
@@ -706,6 +706,7 @@ A metaobject entry (`metaobjects.<type>.<handle>`).
706
706
  |---|---|---|
707
707
  | `handle` | string | **Required.** |
708
708
  | `fields` | map of [Metafield](#metafield) | Field values, with the same shorthand as metafields. |
709
+ | `translations` | map of map of any JSON value | The entry in the store's other languages, by language (an `iso_code` of `localization.languages`) then field key: `{"fr": {"question": "Combien ?"}}`. A value is written as the field's (a string, a rich text object, a list), and only the fields Shopify translates take one: text, rich text, JSON, links, URLs, files, and the lists of single line texts, links, URLs and files. On `/fr/...` a field shows its French value, else its own. |
709
710
  | `template_suffix` | string | Selects `templates/metaobject/<type>.<suffix>.json` instead of `templates/metaobject/<type>.json` for the page of the entry, which is `/pages/<type>/<handle>`. |
710
711
 
711
712
  ## Localization
@@ -730,9 +731,10 @@ A country the store sells to.
730
731
  |---|---|---|
731
732
  | `iso_code` | string | **Required.** ISO 3166-1 alpha-2 code, e.g. `"FR"`. |
732
733
  | `name` | string | Defaults to the English name of the country. |
733
- | `currency` | string | ISO 4217 currency code. Defaults to the store currency. |
734
+ | `currency` | string | ISO 4217 code of the currency its buyers pay in: prices, the cart and the checkout are in it. Defaults to the `currency` of its market, else the store currency. |
734
735
  | `unit_system` | string | `metric` or `imperial`. Defaults to `metric`. |
735
736
  | `popular` | boolean | Whether to list the country among the popular ones. |
737
+ | `provinces` | array of string | The provinces the store ships to, by code (`["CA", "OR", "WA"]`): those a country selector offers (`data-provinces` of `country_option_tags`), sorted by name as Shopify lists them. Defaults to every province Shopify lists for the country (states, regions, prefectures); a country Shopify lists none for has none. |
736
738
 
737
739
  ## Language
738
740
 
@@ -753,7 +755,30 @@ A market: the countries the store sells to as one, as checkout Functions see it
753
755
  |---|---|---|
754
756
  | `handle` | string | **Required.** What Functions call the market: `"europe"`. |
755
757
  | `countries` | array of string | **Required.** The ISO 3166-1 alpha-2 codes of its countries, which are in `localization.countries`. |
758
+ | `languages` | array of string | The languages its countries are offered (`country.available_languages`, in this order), by ISO code, which are in `localization.languages`. The first is the market's default language: a visitor who switches to one of its countries in a language it does not offer gets it. Defaults to every language of the store. |
756
759
  | `metafields` | map of map of [Metafield](#metafield) | |
760
+ | `subfolder_suffix` | string | Serves the market under subfolders of its own, as a Shopify market's web presence does: `"ca"` serves it at `/en-ca/`, `/fr-ca/`, one per language. Two letters at least. Without it, the market is served at `/`, with the primary market. |
761
+ | `currency` | string | The currency its countries pay in, by ISO 4217 code (`"EUR"`), as a Shopify market's base currency: prices, the cart and the checkout are in it. A country's own `currency` wins. Defaults to the store currency. |
762
+ | `rate` | string | What one unit of the store currency is worth in the market's currency, as a decimal string (`"0.92"`): prices are converted with it, as with a manual exchange rate on Shopify. Defaults to 1, since lsf has no exchange rates. |
763
+ | `prices` | map of [FixedPrice](#fixedprice) | Fixed prices in the market's currency, as a Shopify price list has them, which are not converted: by variant (a SKU, an id, or a product handle for each of its variants), a price (`"25.00"`) or `{"price": "25.00", "compare_at_price": "30.00"}`. |
764
+
765
+ ## FixedPrice
766
+
767
+ A fixed price of a market: a price, or a price and a compare-at price.
768
+
769
+ One of:
770
+
771
+ - [Money](#money)
772
+ - [FixedPriceObject](#fixedpriceobject)
773
+
774
+ ## FixedPriceObject
775
+
776
+ A fixed price with its compare-at price.
777
+
778
+ | Field | Type | Description |
779
+ |---|---|---|
780
+ | `price` | [Money](#money) | **Required.** |
781
+ | `compare_at_price` | [Money](#money) | None when omitted: a fixed price has the compare-at price it is given. |
757
782
 
758
783
  ## Location
759
784
 
@@ -263,4 +263,5 @@ Times are in milliseconds.
263
263
  that a section writes itself, in a loop over `section.blocks`, are part of the section.
264
264
  - `/__lsf/timings` counts pages by template, sections and blocks by id. Sections and blocks
265
265
  count wherever they are rendered: in a page, alone, or in a cart response. The id of a
266
- block is the one of `#shopify-block-<id>`, and ends with its key.
266
+ block is the one of `#shopify-block-<id>` the first time it renders, and ends with its key:
267
+ the renders after it (`<id>-1`, `<id>-2`, as on Shopify) count under it.
@@ -16,10 +16,24 @@ on a WebSocket, and asks the server every 700 ms where a socket cannot be opened
16
16
  script to the pages: leave it off for tests. Only HTML pages get it: a `{% layout none %}`
17
17
  template that writes JSON is left as it is.
18
18
 
19
+ An edit of `assets/` needs no restart either, with or without `--live-reload`: a stylesheet, a
20
+ script, an image, a `.css.liquid` or `.js.liquid` file, the bundle of the `{% stylesheet %}` and
21
+ `{% javascript %}` tags, and the assets of a [theme app extension](#theme-app-extensions). The
22
+ page links each file at a new `?v=` once it changes, as Shopify does, and the browser fetches
23
+ it there. A file the theme loads without its `?v=` (a `url()` in a stylesheet, an `import` in a
24
+ script) is kept a year by the browser, as from Shopify's CDN: link it with `asset_url`, or
25
+ reload without the cache. `--live-reload` reloads the whole page when an asset changes; it does
26
+ not swap a stylesheet in place.
27
+
19
28
  With `--live-reload`, a page with problems also shows a small panel: the errors of the store
20
29
  data, the Liquid errors of the page, and why a page is the 404 page. Its button closes it. The
21
30
  terminal has the same problems.
22
31
 
32
+ The panel also has notes, which are not problems. A page that renders one of
33
+ [lsf's default menus](data-format.md#lsfs-default-menus) gets a note: its links are lsf's,
34
+ since the data has no menu with that handle. Without problems, the panel has a grey border
35
+ instead of a red one.
36
+
23
37
  ## Log in as a customer
24
38
 
25
39
  Open <http://127.0.0.1:9292/account>. The page lists the customers of your data: click one to
@@ -74,8 +88,10 @@ in.
74
88
 
75
89
  ## What else is served as on Shopify
76
90
 
77
- - **Styles and scripts are minified**, with a source map, as Shopify minifies them.
78
- `--no-minify` serves them as written.
91
+ - **Styles and scripts are minified**, with a source map, as Shopify minifies them: when that
92
+ makes the file lighter, and without merging declarations that cascade apart, such as
93
+ `translate` into `transform` ([the rules](compatibility.md#minification)). `--no-minify`
94
+ serves them as written.
79
95
  - **Responses are compressed** with Brotli or gzip, with Shopify's `Cache-Control`, so that an
80
96
  audit of your theme sees what it would see on the store. `--no-compression` turns it off.
81
97
  - **Pages have Shopify's scripts:** the `Shopify` object, `Shopify.actions`,
@@ -87,6 +103,31 @@ in.
87
103
  Shopify signs it, and a Liquid answer is rendered in the theme's layout. See the
88
104
  [Admin API guide](admin.md#app-proxy).
89
105
 
106
+ ## Countries and markets in the URL
107
+
108
+ A visitor's country comes from the URL as on a Shopify storefront, besides the localization form
109
+ and `PUT /__lsf/session`:
110
+
111
+ - **`?country=FR`** on any page picks a country the store sells to, for the rest of the session.
112
+ When the country's market is served under subfolders of its own, the answer is a `301` to the
113
+ same page there, the query kept: `/products/tee?country=BE` → `/en-be/products/tee?country=BE`,
114
+ in the page's language when the market offers it, else in the market's default.
115
+ - **A market's subfolders**: a market with a `subfolder_suffix` (`"be"`, see
116
+ [localization](data-format.md#localization)) is served under `/<language>-<suffix>/`, one per
117
+ language of the market (`/en-be/`, `/fr-be/`; every language of the store when it lists no
118
+ `languages`). A subfolder in a language the market does not offer is not found, as on Shopify. A page there is in the visitor's country when the market has
119
+ it, else in the market's first country, in the language of the prefix; every link the theme
120
+ makes stays under it (`routes`, `request.locale.root_url`, the `root_url` of the languages,
121
+ `Shopify.routes.root`). A page at `/` is in the visitor's country when `/` serves it, else in
122
+ the shop's: a country picked under a subfolder does not follow the visitor to `/`.
123
+ - **The localization form** (`{% form 'localization' %}`) sends the visitor to the same page on
124
+ the web presence of the country they picked: `country_code=BE` → `/en-be/...`, and back to `/`
125
+ for a country served there, in a language the country's market offers.
126
+ - **The currency follows the country**: prices, the cart (`cart.currency`, `/cart.js`), the money
127
+ filters and the checkout are in the country's currency, at its market's rate or fixed prices
128
+ (see [localization](data-format.md#localization)), and `Shopify.currency` gives the currency
129
+ and the rate (`{"active":"EUR","rate":"0.92"}`).
130
+
90
131
  ## Theme app extensions
91
132
 
92
133
  With `--app`, the theme renders the app blocks and app embed blocks of the app's theme app
package/docs/testing.md CHANGED
@@ -514,8 +514,8 @@ Everything a theme talks to on a storefront:
514
514
  | Customer accounts | With new customer accounts, `/account` is a page of local-storefront where the visitor chooses who is logged in; `/account/login`, `/customer_authentication/login` and the other account URLs redirect to it. `/company_location/update?location_id=<id>&return_to=<path>` changes the location of a B2B customer. |
515
515
  | Robots and sitemaps | `/robots.txt` (from `templates/robots.txt.liquid`, or Shopify's default rules), `/sitemap.xml` and `/sitemap_<products|pages|collections|blogs>_1.xml`. |
516
516
  | Alternate templates | `?view=<suffix>` and the `template_suffix` of the resource. |
517
- | Section Rendering API | `?section_id=<id>` and `?sections=<id>,<id>` on any page. An id the page does not have is a 404 for `section_id` and `null` in `sections`; either way `x-lsf-not-found` names it and lists the ids the page has. |
518
- | Cart Ajax API | `GET /cart.js`, `POST /cart/add.js`, `/cart/change.js`, `/cart/update.js`, `/cart/clear.js` (with `sections` and `sections_url`), and the form posts `/cart/add`, `/cart`. Stock limits answer `422` like Shopify. |
517
+ | Section Rendering API | `?section_id=<id>` and `?sections=<id>,<id>` on any page. An id the page does not have is a 404 for `section_id` and `null` in `sections`; either way `x-lsf-not-found` names it and lists the ids the page has. An id asked for twice in `sections` is rendered once, as on Shopify. |
518
+ | Cart Ajax API | `GET /cart.js`, `POST /cart/add.js`, `/cart/change.js`, `/cart/update.js`, `/cart/clear.js` (with `sections` and `sections_url`), and the form posts `/cart/add`, `/cart`. `add.js` and `change.js` keep to the stock as Shopify does: more than the stock puts what is in stock in the cart and answers `422`. `update.js` does not check it (Shopify does not either for the lines already in the cart). |
519
519
  | Product and search JSON | `/products/<handle>.js`, `/products.json`, `/collections/<handle>/products.json`, `/search/suggest.json`, `/search/suggest?section_id=`, `/recommendations/products.json`, `/recommendations/products?section_id=&product_id=`. |
520
520
  | Forms | Contact, newsletter (`customer`), blog comment, customer logout, localization (country and language), storefront password. The outcome shows in `form.posted_successfully?` and `form.errors` on the next page, once. A wrong storefront password comes back to `/password` with the error; the right one (`password`, or `shop.password`) goes to the home page. |
521
521
  | CDN | `/cdn/shop/t/1/assets/<file>` (including `.liquid` assets), `/cdn/shop/files/<path>` with image transformations, the compiled `{% stylesheet %}` and `{% javascript %}` bundles, fonts. |
package/package.json CHANGED
@@ -1,12 +1,18 @@
1
1
  {
2
2
  "name": "local-storefront",
3
- "version": "0.3.0-rc.1",
4
- "description": "A local storefront for Shopify themes: lsf renders a theme from JSON fixtures, without the Shopify API, for fast end-to-end tests.",
3
+ "version": "0.3.0-rc.3",
4
+ "description": "Run Shopify themes and apps locally, without Shopify: the storefront, a local checkout for Functions and checkout UI extensions, and a local GraphQL Admin API with webhooks, from JSON store data. Made for tests in CI.",
5
5
  "keywords": [
6
6
  "shopify",
7
7
  "theme",
8
8
  "liquid",
9
9
  "storefront",
10
+ "shopify-app",
11
+ "shopify-functions",
12
+ "checkout",
13
+ "admin-api",
14
+ "graphql",
15
+ "webhooks",
10
16
  "e2e",
11
17
  "testing",
12
18
  "playwright"
@@ -32,11 +38,11 @@
32
38
  "node": ">=18"
33
39
  },
34
40
  "optionalDependencies": {
35
- "local-storefront-darwin-arm64": "0.3.0-rc.1",
36
- "local-storefront-darwin-x64": "0.3.0-rc.1",
37
- "local-storefront-linux-arm64": "0.3.0-rc.1",
38
- "local-storefront-linux-x64": "0.3.0-rc.1",
39
- "local-storefront-win32-arm64": "0.3.0-rc.1",
40
- "local-storefront-win32-x64": "0.3.0-rc.1"
41
+ "local-storefront-darwin-arm64": "0.3.0-rc.3",
42
+ "local-storefront-darwin-x64": "0.3.0-rc.3",
43
+ "local-storefront-linux-arm64": "0.3.0-rc.3",
44
+ "local-storefront-linux-x64": "0.3.0-rc.3",
45
+ "local-storefront-win32-arm64": "0.3.0-rc.3",
46
+ "local-storefront-win32-x64": "0.3.0-rc.3"
41
47
  }
42
48
  }