local-storefront 0.2.0 → 0.3.0-rc.2
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 +1384 -0
- package/README.md +14 -9
- package/bin/lsf.js +4 -1
- package/docs/README.md +59 -0
- package/docs/admin-reference.md +1412 -0
- package/docs/admin.md +1751 -0
- package/docs/agents.md +366 -0
- package/docs/architecture.md +303 -0
- package/docs/checkout.md +1612 -0
- package/docs/cli.md +917 -0
- package/docs/compatibility.md +502 -0
- package/docs/control-api.md +251 -0
- package/docs/data-format.md +1161 -0
- package/docs/data-reference.md +1272 -0
- package/docs/formatting.md +119 -0
- package/docs/performance.md +267 -0
- package/docs/quickstart-admin-api.md +211 -0
- package/docs/quickstart-app.md +271 -0
- package/docs/quickstart-functions.md +207 -0
- package/docs/quickstart-theme.md +177 -0
- package/docs/storefront.md +251 -0
- package/docs/strict-liquid.md +178 -0
- package/docs/testing.md +590 -0
- package/llms.txt +39 -0
- package/package.json +18 -9
|
@@ -0,0 +1,1161 @@
|
|
|
1
|
+
# Store data format
|
|
2
|
+
|
|
3
|
+
local-storefront renders a Shopify theme from JSON files instead of the Shopify API. This
|
|
4
|
+
document describes those files. It is printed by `npx local-storefront docs`, and
|
|
5
|
+
`npx local-storefront init` writes it next to the data as `README.md`.
|
|
6
|
+
|
|
7
|
+
The format is strict on purpose: unknown fields, wrong types and broken references are errors.
|
|
8
|
+
An unknown field is left out, and the rest of its file is still read.
|
|
9
|
+
Run `npx local-storefront validate` after every change. Each problem is reported with a code,
|
|
10
|
+
the file, a JSON pointer to the value, and usually a hint:
|
|
11
|
+
|
|
12
|
+
```text
|
|
13
|
+
error[unknown_field]: unknown field "titel"
|
|
14
|
+
--> products/shirt.json at /titel
|
|
15
|
+
hint: did you mean "title"?
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`npx local-storefront validate --format json` prints the same report as JSON, for tools and
|
|
19
|
+
LLMs:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"ok": false,
|
|
24
|
+
"errors": 1,
|
|
25
|
+
"warnings": 0,
|
|
26
|
+
"data": "./shopify-local",
|
|
27
|
+
"diagnostics": [
|
|
28
|
+
{
|
|
29
|
+
"severity": "error",
|
|
30
|
+
"code": "unknown_field",
|
|
31
|
+
"file": "products/shirt.json",
|
|
32
|
+
"path": "/titel",
|
|
33
|
+
"message": "unknown field \"titel\"",
|
|
34
|
+
"hint": "did you mean \"title\"?"
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"app": null
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The exit status is `0` when the data is valid and `1` when it has errors. Warnings do not fail.
|
|
42
|
+
|
|
43
|
+
## The data directory
|
|
44
|
+
|
|
45
|
+
By default the data is read from `shopify-local/` inside the theme. Pass `--data <dir>` (or
|
|
46
|
+
set `LSF_DATA`) to use another directory. Without any data directory, a built-in demo store is
|
|
47
|
+
used; `npx local-storefront init` writes that demo store to disk as a starting point.
|
|
48
|
+
|
|
49
|
+
```text
|
|
50
|
+
shopify-local/
|
|
51
|
+
store.json data files: any name, any number, merged together
|
|
52
|
+
menus.json
|
|
53
|
+
products/ one product per file; the file name is the default handle
|
|
54
|
+
blue-shirt.json
|
|
55
|
+
collections/ same for collections,
|
|
56
|
+
pages/ pages,
|
|
57
|
+
blogs/ blogs (with their articles),
|
|
58
|
+
customers/ customers,
|
|
59
|
+
menus/ and menus (the file name is the menu handle)
|
|
60
|
+
checkouts/ checkout scenarios, one named setup of the local checkout per file
|
|
61
|
+
files/ images, videos, fonts: served under /cdn/shop/files/
|
|
62
|
+
schema/ JSON Schemas for editors, written by `init` (not read back)
|
|
63
|
+
README.md this document
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Starting from your own data.** The files of the demo store name each other:
|
|
67
|
+
`collections.json`, `menus.json` and the `selling_plan_groups` of `store.json` name its
|
|
68
|
+
products, `menus.json` names its pages and its blog, and `gift_cards.json` names a product and
|
|
69
|
+
a customer. Replace `products/` and `customers.json` with yours, and edit `collections.json`
|
|
70
|
+
and `menus.json`. Delete what you don't need: `gift_cards.json`, the `selling_plan_groups` of
|
|
71
|
+
`store.json`. Then run `npx local-storefront validate`: each name left over is an error
|
|
72
|
+
(`unknown_product`, `unknown_reference`, `unknown_customer`) with its file and pointer. Keep
|
|
73
|
+
`schema/`, for your editor.
|
|
74
|
+
|
|
75
|
+
No error points at the rest of the demo content, so replace it too: the shop's name in
|
|
76
|
+
`store.json` (`Local Supply Co.`), the pages of `pages.json` and the articles of `blogs.json`
|
|
77
|
+
(or delete them, with their links in `menus.json`). The demo's automated
|
|
78
|
+
[collections](#collections) pick products by `type` and by the tag `sale`: give yours a `type`
|
|
79
|
+
and `tags`, or these collections stay empty.
|
|
80
|
+
|
|
81
|
+
Every `*.json` file at the root is a **data file**: an object with any of the keys `shop`,
|
|
82
|
+
`products`, `collections`, `pages`, `blogs`, `menus`, `customers`, `companies`, `gift_cards`,
|
|
83
|
+
`selling_plan_groups`, `locations`, `swatches`, `metaobjects`, `localization`, `files`,
|
|
84
|
+
`session`, `checkout`, `now`, `theme_settings` and `app`. Root files are merged, so the data can be
|
|
85
|
+
split however is convenient. Lists add up; `shop`, `localization`, `session`, `checkout` and
|
|
86
|
+
`now` may be defined in one file only.
|
|
87
|
+
|
|
88
|
+
A file in `products/`, `collections/`, `pages/`, `blogs/` or `customers/` holds one entity, or
|
|
89
|
+
an array of them. Files are read in alphabetical order, which is the order products matched by
|
|
90
|
+
the rules of an automated collection are listed in.
|
|
91
|
+
|
|
92
|
+
JSON files may contain `//` and `/* */` comments.
|
|
93
|
+
|
|
94
|
+
### Editor support
|
|
95
|
+
|
|
96
|
+
Point `$schema` at the schema of the file's kind to get completion and inline errors:
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{ "$schema": "../schema/product.schema.json", "title": "Blue shirt", "price": "19.99" }
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`npx local-storefront schema <kind>` prints a schema (`store`, `product`, `collection`, `page`,
|
|
103
|
+
`blog`, `customer`, `menu`, `session`, `checkout-scenario`), `npx local-storefront schema --out <dir>` writes them
|
|
104
|
+
all, and a running server serves them at `/__lsf/schema/<kind>`.
|
|
105
|
+
|
|
106
|
+
## Conventions
|
|
107
|
+
|
|
108
|
+
- **Unknown fields are errors.** A typo never silently does nothing.
|
|
109
|
+
- **Ids and handles are optional.** A handle is derived from the title (`"Blue Shirt"` →
|
|
110
|
+
`blue-shirt`), or from the file name for one-entity files. Ids are derived from handles. Both
|
|
111
|
+
are stable across runs and machines, so URLs and ids can be hard-coded in tests. Set `id`
|
|
112
|
+
explicitly only to reproduce a specific Shopify id.
|
|
113
|
+
- **Money** is an integer number of cents (`1999`) or a decimal string (`"19.99"`). A JSON
|
|
114
|
+
float (`19.99`) is rejected, because it is ambiguous. Liquid sees cents, as on Shopify.
|
|
115
|
+
- **Dates** are ISO 8601 strings: a day, `"2024-05-01"`, or a day and a time,
|
|
116
|
+
`"2024-05-01T10:00:00Z"` (or a space for the `T`), with `Z`, an offset (`+02:00`) or neither,
|
|
117
|
+
for the shop's `timezone`. `now` is a date too. Any other form (`"May 1, 2024"`,
|
|
118
|
+
`"2024/05/01"`, `"2024-5-1"`, an RFC 2822 date, a time alone) is an `invalid_date` error,
|
|
119
|
+
though the `date` filter of a theme still reads it. Entities without dates get fixed ones, so
|
|
120
|
+
renders are reproducible.
|
|
121
|
+
- **References** between entities use handles (products, collections, pages, blogs), emails
|
|
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. A metafield also takes Shopify's GIDs
|
|
124
|
+
([reference values](#reference-values)).
|
|
125
|
+
- **HTML** fields (`description`, `content`, `body`) are inserted as written.
|
|
126
|
+
|
|
127
|
+
## Products
|
|
128
|
+
|
|
129
|
+
The smallest product:
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{ "title": "Blue shirt", "price": "19.99" }
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
It gets a single default variant, like a product without options in the Shopify admin. A
|
|
136
|
+
product with options lists its variants; each gives one value per option:
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"title": "Organic Cotton T-Shirt",
|
|
141
|
+
"vendor": "Local Supply Co.",
|
|
142
|
+
"type": "Shirts",
|
|
143
|
+
"tags": ["cotton", "new"],
|
|
144
|
+
"options": ["Color", "Size"],
|
|
145
|
+
"price": "28.00",
|
|
146
|
+
"compare_at_price": "35.00",
|
|
147
|
+
"images": [
|
|
148
|
+
{ "src": "products/tee-white.jpg", "alt": "White t-shirt", "width": 1600, "height": 2000 },
|
|
149
|
+
"products/tee-black.jpg"
|
|
150
|
+
],
|
|
151
|
+
"variants": [
|
|
152
|
+
{ "options": ["White", "S"], "sku": "TEE-WHT-S", "inventory_quantity": 12, "image": "products/tee-white.jpg" },
|
|
153
|
+
{ "options": ["White", "M"], "sku": "TEE-WHT-M", "inventory_quantity": 0 },
|
|
154
|
+
{ "options": ["Black", "S"], "sku": "TEE-BLK-S", "price": "30.00", "available": false }
|
|
155
|
+
],
|
|
156
|
+
"collections": ["apparel"],
|
|
157
|
+
"metafields": { "custom": { "care": "Machine wash cold" } }
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
- `price` and `compare_at_price` on the product apply to the variants that do not set their own.
|
|
162
|
+
- **Availability** follows Shopify's rules. A variant with `inventory_quantity` is tracked: it
|
|
163
|
+
is available while the quantity is above zero, or always with
|
|
164
|
+
`"inventory_policy": "continue"`. A variant without `inventory_quantity` is not tracked and
|
|
165
|
+
always available. `available` forces the answer either way. `"available": false` alone also
|
|
166
|
+
makes the variant tracked with a quantity of 0, as a sold-out variant is on Shopify, so themes
|
|
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.
|
|
169
|
+
`"available": false` with an `inventory_quantity` above 0 is a warning
|
|
170
|
+
(`unavailable_in_stock`): Shopify never has both. lsf keeps the variant unavailable with its
|
|
171
|
+
stock, which Horizon shows as "Unavailable".
|
|
172
|
+
- `options` can spell out the order of the values:
|
|
173
|
+
`{ "name": "Size", "values": ["S", "M", "L"] }`. A value can carry a [swatch](#swatches).
|
|
174
|
+
- `selling_plan_groups` and `requires_selling_plan` sell the product by subscription: see
|
|
175
|
+
[selling plans](#selling-plans).
|
|
176
|
+
- A variant's `store_availabilities` says where it can be picked up: see
|
|
177
|
+
[store pickup](#store-pickup).
|
|
178
|
+
- Use `media` instead of `images` to mix images with videos, external videos and 3D models.
|
|
179
|
+
- `recommendations` lists the handles returned by product recommendations; by default they are
|
|
180
|
+
other products of the same collections.
|
|
181
|
+
- `template_suffix` selects an alternate template (`templates/product.<suffix>.json`).
|
|
182
|
+
- `status` (`active`, `draft`, `archived`) and `published` (on the Online Store, `true` by
|
|
183
|
+
default) say whether the product is on the storefront: only an active, published product is.
|
|
184
|
+
The others have no page, are in no collection, cannot be bought, and what names them (a
|
|
185
|
+
collection, a menu, an order) leaves them out without an error; the Admin API lists them with
|
|
186
|
+
their status.
|
|
187
|
+
|
|
188
|
+
## Collections
|
|
189
|
+
|
|
190
|
+
```json
|
|
191
|
+
{
|
|
192
|
+
"collections": [
|
|
193
|
+
{ "title": "Apparel", "products": ["organic-cotton-t-shirt", "linen-overshirt"] },
|
|
194
|
+
{ "title": "Sale", "rules": [{ "column": "tag", "condition": "sale" }] },
|
|
195
|
+
{
|
|
196
|
+
"title": "Under $30",
|
|
197
|
+
"sort_order": "price-ascending",
|
|
198
|
+
"rules": [{ "column": "price", "relation": "less_than", "condition": "3000" }]
|
|
199
|
+
}
|
|
200
|
+
]
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
A product belongs to a collection when the collection lists it in `products`, when the product
|
|
205
|
+
lists the collection in its own `collections`, or when it matches the collection's `rules`
|
|
206
|
+
(all of them, or any of them with `"disjunctive": true`).
|
|
207
|
+
|
|
208
|
+
`collections.all` always exists and contains every product, sorted by title as on Shopify.
|
|
209
|
+
Define a collection with the handle `all` only to change its title or order.
|
|
210
|
+
|
|
211
|
+
Storefront filtering (`collection.filters`, `?filter.v.availability=1`,
|
|
212
|
+
`?filter.v.price.gte=10`, `?filter.p.vendor=...`, `?filter.v.option.color=...`), sorting
|
|
213
|
+
(`?sort_by=`) and tag URLs (`/collections/<handle>/<tag>`) work from this data without any
|
|
214
|
+
extra configuration.
|
|
215
|
+
|
|
216
|
+
## Selling plans
|
|
217
|
+
|
|
218
|
+
A selling plan is a way of buying a product other than once: a subscription, a pre-order.
|
|
219
|
+
Plans come in groups, which is what a subscription app creates.
|
|
220
|
+
|
|
221
|
+
```json
|
|
222
|
+
{
|
|
223
|
+
"selling_plan_groups": [
|
|
224
|
+
{
|
|
225
|
+
"name": "Subscribe and save",
|
|
226
|
+
"options": ["Delivery frequency"],
|
|
227
|
+
"selling_plans": [
|
|
228
|
+
{
|
|
229
|
+
"name": "Deliver every month, 10% off",
|
|
230
|
+
"options": ["Every month"],
|
|
231
|
+
"price_adjustments": [{ "value_type": "percentage", "value": 10 }]
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
"name": "Deliver every 3 months",
|
|
235
|
+
"options": ["Every 3 months"]
|
|
236
|
+
}
|
|
237
|
+
],
|
|
238
|
+
"products": ["organic-cotton-t-shirt"]
|
|
239
|
+
}
|
|
240
|
+
]
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
- A product is sold with a group when the group lists it in `products`, or when the product
|
|
245
|
+
names the group: `"selling_plan_groups": ["Subscribe and save"]`. The plans apply to every
|
|
246
|
+
variant of the product.
|
|
247
|
+
- `"requires_selling_plan": true` on a product means it cannot be bought once.
|
|
248
|
+
- A **price adjustment** has a `value_type`: `percentage` (`value` percent off), `fixed_amount`
|
|
249
|
+
(`value` off, an amount of money) or `price` (`value` is the price). A plan has at most two:
|
|
250
|
+
the first one gives the price, the second one is what later orders cost. Without any, the
|
|
251
|
+
plan costs what the variant costs.
|
|
252
|
+
- `options` of the group are the names of what the plans differ by, and each plan gives one
|
|
253
|
+
value per option. They default to one option, `Delivery frequency`, whose value is the
|
|
254
|
+
plan's name.
|
|
255
|
+
- `checkout_charge` is what is paid at checkout, for a deposit:
|
|
256
|
+
`{ "value_type": "percentage", "value": 50 }`. It defaults to the whole price. The checkout
|
|
257
|
+
of an [app](checkout.md#deferred-payments) charges only that today, and shows the rest as due
|
|
258
|
+
later.
|
|
259
|
+
- `recurring_deliveries` defaults to `true`. Ids are generated and stable; set `id` on a plan
|
|
260
|
+
to reproduce a Shopify id.
|
|
261
|
+
|
|
262
|
+
In Liquid, `product.selling_plan_groups`, `variant.selling_plan_allocations` and
|
|
263
|
+
`product.selected_or_first_available_selling_plan_allocation` are filled. `?selling_plan=<id>`
|
|
264
|
+
selects a plan (`product.selected_selling_plan`, `selling_plan.selected`), and with `?variant=`
|
|
265
|
+
an allocation (`product.selected_selling_plan_allocation`). `/products/<handle>.js` and the
|
|
266
|
+
`json` filter list the groups and the allocations as Shopify does.
|
|
267
|
+
|
|
268
|
+
In the cart, `selling_plan` is accepted by `/cart/add.js` and `/cart/change.js`. The line then
|
|
269
|
+
has the plan's price and its `selling_plan_allocation`, and stays separate from the same
|
|
270
|
+
variant bought once. A product is refused with a plan it is not sold with, and without one
|
|
271
|
+
when it requires one.
|
|
272
|
+
|
|
273
|
+
## Store pickup
|
|
274
|
+
|
|
275
|
+
`locations` are the places the store stocks its products at.
|
|
276
|
+
|
|
277
|
+
```json
|
|
278
|
+
{
|
|
279
|
+
"locations": [
|
|
280
|
+
{
|
|
281
|
+
"name": "Portland flagship",
|
|
282
|
+
"address": {
|
|
283
|
+
"address1": "12 Maple Street",
|
|
284
|
+
"city": "Portland",
|
|
285
|
+
"province_code": "OR",
|
|
286
|
+
"zip": "97204",
|
|
287
|
+
"country_code": "US",
|
|
288
|
+
"phone": "+1 503 555 0100"
|
|
289
|
+
},
|
|
290
|
+
"pick_up_time": "Usually ready in 2 hours",
|
|
291
|
+
"latitude": 45.5202,
|
|
292
|
+
"longitude": -122.6742
|
|
293
|
+
},
|
|
294
|
+
{ "name": "Seattle warehouse", "pick_up_enabled": false }
|
|
295
|
+
]
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
- `pick_up_enabled` and `physical_storefront` default to `true`, `pick_up_time` to
|
|
300
|
+
`Usually ready in 24 hours`.
|
|
301
|
+
- Every location stocks every variant, and a variant is in stock there when it is available. A
|
|
302
|
+
variant that lists `store_availabilities` is stocked by those locations only, each with its
|
|
303
|
+
own stock:
|
|
304
|
+
`"store_availabilities": { "Portland flagship": true, "Seattle warehouse": false }`.
|
|
305
|
+
|
|
306
|
+
In Liquid, `variant.store_availabilities` lists a `store_availability` per location, with its
|
|
307
|
+
`location` (`name`, `address`, `latitude`, `longitude`, `metafields`). As on Shopify, only the
|
|
308
|
+
selected variant and the first available one have them: the other variants of a product have
|
|
309
|
+
an empty list.
|
|
310
|
+
|
|
311
|
+
`/variants/<id>?section_id=<section>` renders a section for a variant, which the section reads
|
|
312
|
+
as `product_variant`. Themes fetch that URL to show pickup availability. Without `section_id`,
|
|
313
|
+
the URL leads to the product with the variant selected.
|
|
314
|
+
|
|
315
|
+
## Swatches
|
|
316
|
+
|
|
317
|
+
A swatch shows an option value as a color or an image instead of its name. `swatches` gives
|
|
318
|
+
them by the name of the value, for the whole store:
|
|
319
|
+
|
|
320
|
+
```json
|
|
321
|
+
{
|
|
322
|
+
"swatches": {
|
|
323
|
+
"White": "#f4f1ea",
|
|
324
|
+
"Sage": "rgb(156, 175, 136)",
|
|
325
|
+
"Floral": { "image": "swatches/floral.jpg" },
|
|
326
|
+
"Sand": { "color": "#d8c7a8", "image": "swatches/sand.jpg" }
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Every option value with one of these names gets the swatch, whatever the product and the
|
|
332
|
+
option. A product can give its own to a value, in the long form of `options`:
|
|
333
|
+
|
|
334
|
+
```json
|
|
335
|
+
{
|
|
336
|
+
"options": [
|
|
337
|
+
{ "name": "Color", "values": ["White", { "name": "Sunset", "swatch": "#ff7a00" }] },
|
|
338
|
+
"Size"
|
|
339
|
+
]
|
|
340
|
+
}
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
In Liquid, `product_option_value.swatch` has a `color` and an `image`, and is `nil` for a
|
|
344
|
+
value without a swatch. In collection filters, a filter whose values have swatches has the
|
|
345
|
+
`swatch` presentation, and each `filter_value.swatch` is filled.
|
|
346
|
+
|
|
347
|
+
## Pages, blogs and articles
|
|
348
|
+
|
|
349
|
+
```json
|
|
350
|
+
{
|
|
351
|
+
"pages": [
|
|
352
|
+
{ "title": "About", "content": "<p>Since 2012.</p>" },
|
|
353
|
+
{ "title": "Contact", "template_suffix": "contact" }
|
|
354
|
+
],
|
|
355
|
+
"blogs": [
|
|
356
|
+
{
|
|
357
|
+
"title": "Journal",
|
|
358
|
+
"comments_enabled": true,
|
|
359
|
+
"articles": [
|
|
360
|
+
{
|
|
361
|
+
"title": "How to care for linen",
|
|
362
|
+
"author": "Sam",
|
|
363
|
+
"tags": ["care"],
|
|
364
|
+
"published_at": "2024-05-01",
|
|
365
|
+
"image": { "src": "blog/linen.jpg", "width": 1600, "height": 900 },
|
|
366
|
+
"excerpt": "<p>Linen gets better with every wash.</p>",
|
|
367
|
+
"content": "<p>Wash cold, hang dry.</p>",
|
|
368
|
+
"comments": [{ "author": "Alex", "content": "Great tips." }]
|
|
369
|
+
}
|
|
370
|
+
]
|
|
371
|
+
}
|
|
372
|
+
]
|
|
373
|
+
}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
## Menus
|
|
377
|
+
|
|
378
|
+
`menus` maps a menu handle (`main-menu`, `footer`, ...) to its links. A link points at a
|
|
379
|
+
resource with one of `url`, `collection`, `product`, `page`, `blog`, `article`
|
|
380
|
+
(`"<blog>/<article>"`) or `policy`; its URL, `link.type` and `link.object` follow from it.
|
|
381
|
+
`link.active` and `link.child_active` follow from the page being rendered.
|
|
382
|
+
|
|
383
|
+
```json
|
|
384
|
+
{
|
|
385
|
+
"menus": {
|
|
386
|
+
"main-menu": {
|
|
387
|
+
"links": [
|
|
388
|
+
{ "title": "Home", "url": "/" },
|
|
389
|
+
{
|
|
390
|
+
"title": "Shop",
|
|
391
|
+
"collection": "all",
|
|
392
|
+
"links": [{ "title": "Apparel", "collection": "apparel" }]
|
|
393
|
+
},
|
|
394
|
+
{ "title": "About", "page": "about" }
|
|
395
|
+
]
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
```
|
|
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
|
+
|
|
437
|
+
## The shop
|
|
438
|
+
|
|
439
|
+
Every field of `shop` is optional.
|
|
440
|
+
|
|
441
|
+
```json
|
|
442
|
+
{
|
|
443
|
+
"shop": {
|
|
444
|
+
"name": "Local Supply Co.",
|
|
445
|
+
"currency": "EUR",
|
|
446
|
+
"money_format": "{{amount_with_comma_separator}} €",
|
|
447
|
+
"timezone": "Europe/Paris",
|
|
448
|
+
"policies": { "refund_policy": { "body": "<p>30 days.</p>" } },
|
|
449
|
+
"enabled_payment_types": ["visa", "master", "paypal"]
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
- `money_format` and `money_with_currency_format` default to the usual format of the currency.
|
|
455
|
+
- `timezone` is the zone dates are displayed in by the `date` filter.
|
|
456
|
+
- `password` is the password the `/password` page accepts. It is `password` when you do not
|
|
457
|
+
set it. The storefront is never locked: the page is always there, and nothing leads to it.
|
|
458
|
+
- Each policy that is set gets a page under `/policies/`.
|
|
459
|
+
|
|
460
|
+
## Customers and sessions
|
|
461
|
+
|
|
462
|
+
A **customer** exists in the store. A **session** is one browser's state: who is logged in,
|
|
463
|
+
what is in the cart, which country is selected.
|
|
464
|
+
|
|
465
|
+
```json
|
|
466
|
+
{
|
|
467
|
+
"customers": [
|
|
468
|
+
{
|
|
469
|
+
"email": "jane.doe@example.com",
|
|
470
|
+
"first_name": "Jane",
|
|
471
|
+
"last_name": "Doe",
|
|
472
|
+
"tags": ["vip"],
|
|
473
|
+
"addresses": [{ "address1": "12 Maple Street", "city": "Portland", "country_code": "US" }],
|
|
474
|
+
"orders": [{ "line_items": [{ "variant": "TEE-WHT-S", "quantity": 2 }] }]
|
|
475
|
+
}
|
|
476
|
+
],
|
|
477
|
+
"session": {
|
|
478
|
+
"customer": "jane.doe@example.com",
|
|
479
|
+
"cart": { "items": [{ "variant": "TEE-WHT-S", "quantity": 1 }], "note": "Gift wrap" },
|
|
480
|
+
"country": "FR"
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
`session` is what every new browser session starts with. Leave it out for an anonymous visitor
|
|
486
|
+
with an empty cart. A variant is referenced by its SKU, by its id, or by a product handle
|
|
487
|
+
(meaning the product's first variant).
|
|
488
|
+
|
|
489
|
+
A cart item bought with a [selling plan](#selling-plans) names the plan, or gives its id:
|
|
490
|
+
`{ "variant": "TEE-WHT-S", "selling_plan": "Deliver every month, 10% off" }`.
|
|
491
|
+
|
|
492
|
+
`session.customer` is the email of a customer, `"default"` for the first customer of the data,
|
|
493
|
+
or `"none"`. When the data has no customer at all, there is still one to log in as:
|
|
494
|
+
`customer@example.com`.
|
|
495
|
+
|
|
496
|
+
### Logging in
|
|
497
|
+
|
|
498
|
+
Customer accounts are Shopify's new customer accounts: they are hosted by Shopify and are not
|
|
499
|
+
part of a theme. Here, `/account` is a page of local-storefront that stands in for them. It
|
|
500
|
+
lists the customers of the data: click one to be logged in as them, or "Nobody" to be logged
|
|
501
|
+
out. Every account link of a theme (`routes.account_login_url`, `routes.storefront_login_url`,
|
|
502
|
+
...) leads to that page, and so do its login and register forms.
|
|
503
|
+
|
|
504
|
+
Classic customer accounts are not simulated: the theme's `templates/customers` are never
|
|
505
|
+
rendered (`serve` and `check` say so), and customers have no password. Data that still has
|
|
506
|
+
`shop.customer_accounts` or a customer's `password` is read as before: those fields are ignored,
|
|
507
|
+
with a warning to remove them.
|
|
508
|
+
|
|
509
|
+
`npx local-storefront serve --customer <email>` starts every visitor logged in as that customer.
|
|
510
|
+
It also accepts `default` and `none`.
|
|
511
|
+
|
|
512
|
+
At the [checkout of an app](checkout.md#log-in-as-a-customer), the customers are the login
|
|
513
|
+
profiles: "Log in" in the Contact section lists them, and picking one logs the checkout (and the
|
|
514
|
+
session) in as them, with their saved addresses (the first one is the default) and, for a B2B
|
|
515
|
+
customer, the locations they buy for. Give the data a customer of each kind you test: a new one
|
|
516
|
+
(no orders, no addresses), a returning one (`addresses`, `orders`, `tags`, `metafields`), a B2B
|
|
517
|
+
buyer with several `company_locations`, a customer in another country. The demo store has one of
|
|
518
|
+
each.
|
|
519
|
+
|
|
520
|
+
### B2B customers
|
|
521
|
+
|
|
522
|
+
A B2B customer buys for a **company**, at one of its **locations**. Describe the company, then
|
|
523
|
+
name it in the customer:
|
|
524
|
+
|
|
525
|
+
```json
|
|
526
|
+
{
|
|
527
|
+
"companies": [
|
|
528
|
+
{
|
|
529
|
+
"name": "Northwind Hotels",
|
|
530
|
+
"external_id": "NW-001",
|
|
531
|
+
"metafields": { "custom": { "payment_terms": "Net 30" } },
|
|
532
|
+
"locations": [
|
|
533
|
+
{
|
|
534
|
+
"name": "Northwind Portland",
|
|
535
|
+
"tax_registration_id": "93-1234567",
|
|
536
|
+
"shipping_address": { "address1": "815 Harbor Way", "city": "Portland", "country_code": "US" },
|
|
537
|
+
"metafields": { "custom": { "delivery_notes": "Loading dock B" } }
|
|
538
|
+
},
|
|
539
|
+
{ "name": "Northwind Seattle" }
|
|
540
|
+
]
|
|
541
|
+
}
|
|
542
|
+
],
|
|
543
|
+
"customers": [
|
|
544
|
+
{
|
|
545
|
+
"email": "alex.morgan@example.com",
|
|
546
|
+
"first_name": "Alex",
|
|
547
|
+
"company": "Northwind Hotels",
|
|
548
|
+
"metafields": { "custom": { "job_title": "Purchasing manager" } }
|
|
549
|
+
}
|
|
550
|
+
]
|
|
551
|
+
}
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
- The customer can buy for every location of the company. List some in `company_locations` to
|
|
555
|
+
restrict them: `"company_locations": ["Northwind Seattle"]`.
|
|
556
|
+
- They start at the first location. `session.company_location` names another one.
|
|
557
|
+
- In Liquid, `customer.b2b?` is true, and `customer.current_company`,
|
|
558
|
+
`customer.current_location` and `customer.company_available_locations` are filled.
|
|
559
|
+
- `location.url_to_set_as_current` changes the location, as on Shopify.
|
|
560
|
+
- The [local checkout](checkout.md) shows what Shopify's Functions read of a B2B buyer
|
|
561
|
+
(`purchasingCompany`), and you can set it. A company has `created_at` and `updated_at`, a
|
|
562
|
+
location the same plus `locale` (`"fr"`), `orders_count` and `total_spent` (`"1250.00"`). The
|
|
563
|
+
customer is the contact of their company: `company_contact` holds their `title`, `locale`,
|
|
564
|
+
`created_at` and `updated_at`.
|
|
565
|
+
|
|
566
|
+
```json
|
|
567
|
+
{
|
|
568
|
+
"companies": [
|
|
569
|
+
{
|
|
570
|
+
"name": "Northwind Hotels",
|
|
571
|
+
"created_at": "2023-06-01",
|
|
572
|
+
"locations": [
|
|
573
|
+
{ "name": "Northwind Portland", "locale": "en", "orders_count": 12, "total_spent": "8450.00" }
|
|
574
|
+
]
|
|
575
|
+
}
|
|
576
|
+
],
|
|
577
|
+
"customers": [
|
|
578
|
+
{
|
|
579
|
+
"email": "alex.morgan@example.com",
|
|
580
|
+
"company": "Northwind Hotels",
|
|
581
|
+
"company_contact": { "title": "Purchasing manager", "locale": "en" }
|
|
582
|
+
}
|
|
583
|
+
]
|
|
584
|
+
}
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
What is not set has a value: the dates are 2024-01-01 12:00 UTC plus one day per company,
|
|
588
|
+
the dates of a location and of a contact are those of their company (`updated_at` those of
|
|
589
|
+
`created_at`), the language is empty, and a location has no order and has spent nothing. The
|
|
590
|
+
orders of the customers are not counted in `orders_count`: it is what you write.
|
|
591
|
+
|
|
592
|
+
Catalogs are not simulated: a B2B customer sees the same products and prices as everyone.
|
|
593
|
+
|
|
594
|
+
Tests usually set the session per test rather than in the files, by sending it to the running
|
|
595
|
+
server. The body is a session, optionally with a `data` object: a data file applied on top of
|
|
596
|
+
the files on disk for that session only.
|
|
597
|
+
|
|
598
|
+
```bash
|
|
599
|
+
curl -X PUT http://127.0.0.1:9292/__lsf/session \
|
|
600
|
+
-H 'x-lsf-session: my-test' -H 'content-type: application/json' \
|
|
601
|
+
-d '{"customer": "jane.doe@example.com", "data": {"shop": {"name": "Another name"}}}'
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
A person can do the same from a browser: `/__lsf` lists the customers, and
|
|
605
|
+
`/__lsf/login?customer=<email>` logs the browser in as one of them (`default` and `none` work
|
|
606
|
+
too).
|
|
607
|
+
|
|
608
|
+
A wrong body is answered with `422` and the same diagnostics as
|
|
609
|
+
`npx local-storefront validate --format json`.
|
|
610
|
+
|
|
611
|
+
## Gift cards
|
|
612
|
+
|
|
613
|
+
Each gift card has a page rendered by `templates/gift_card.liquid`. Its URL is listed by
|
|
614
|
+
`npx local-storefront routes`.
|
|
615
|
+
|
|
616
|
+
```json
|
|
617
|
+
{
|
|
618
|
+
"gift_cards": [
|
|
619
|
+
{ "code": "WCGX7X97G74JGDGC", "initial_value": "50.00", "balance": "32.50", "expires_on": "2030-12-31" }
|
|
620
|
+
]
|
|
621
|
+
}
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
## Checkout
|
|
625
|
+
|
|
626
|
+
The `checkout` section sets up the local checkout of a Shopify app (`npx local-storefront
|
|
627
|
+
app dev --app <dir>`, see [checkout](checkout.md)). Every field has a default, and the section
|
|
628
|
+
changes nothing for a theme.
|
|
629
|
+
|
|
630
|
+
```json
|
|
631
|
+
{
|
|
632
|
+
"checkout": {
|
|
633
|
+
"shipping_rates": [{ "handle": "standard", "title": "Standard", "price": "4.90" }],
|
|
634
|
+
"payment_methods": [{ "handle": "card", "name": "Credit card" }],
|
|
635
|
+
"discounts": [
|
|
636
|
+
{
|
|
637
|
+
"function": "volume-discount",
|
|
638
|
+
"title": "Volume",
|
|
639
|
+
"classes": ["PRODUCT"],
|
|
640
|
+
"metafields": { "$app.function-configuration": { "type": "json", "value": { "percentage": 10 } } }
|
|
641
|
+
}
|
|
642
|
+
],
|
|
643
|
+
"functions": { "delivery-rules": { "enabled": true, "metafields": { "$app.function-configuration": { "type": "json", "value": {} } } } },
|
|
644
|
+
"tax_rate": 20
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
- `shipping_rates`: the delivery options offered to every address. Without it, one: "Standard"
|
|
650
|
+
at 5.00.
|
|
651
|
+
- `payment_methods`: without it, "Credit card", "Cash on Delivery" and "Bank Deposit".
|
|
652
|
+
`placements` says where a method is offered, which payment customization Functions read:
|
|
653
|
+
`["PAYMENT_METHOD"]` (the list of the checkout, the default) and/or `["ACCELERATED_CHECKOUT"]`
|
|
654
|
+
(the express buttons). The page shows the methods of `PAYMENT_METHOD` in its list, and those of
|
|
655
|
+
`ACCELERATED_CHECKOUT` as express buttons above the contact section, once the payment
|
|
656
|
+
customizations ran (one can hide a method in one placement and leave it in the other). PayPal
|
|
657
|
+
with PayPal Express on is in both.
|
|
658
|
+
- `discounts`: each one uses a discount Function of the app by its handle. A `code` makes it a
|
|
659
|
+
code discount; without one it is automatic. `classes` (`PRODUCT`, `ORDER`, `SHIPPING`) says
|
|
660
|
+
what it may reduce, all three by default. `metafields` are those of the discount, by
|
|
661
|
+
`"namespace.key"`, in the same shorthand as other metafields. `id` is the number of the id
|
|
662
|
+
its Function reads (`gid://shopify/DiscountAutomaticNode/<id>`), its position in the list from
|
|
663
|
+
1 by default.
|
|
664
|
+
- `functions`: the other Functions (cart transform, delivery or payment customization,
|
|
665
|
+
validation), by the handle of the Function: `metafields`, their settings, and `enabled`, whether
|
|
666
|
+
the shop has an owner of it (the payment customization, … the app creates) and it is on. As on
|
|
667
|
+
Shopify, the shop's checkout runs such a Function only through an enabled owner: `true` runs
|
|
668
|
+
it, `false` is an owner turned off, and without `enabled` the shop has none (as after an
|
|
669
|
+
install, until the app creates one through the Admin API). A scenario that names the Function
|
|
670
|
+
in `extensions` runs it anyway, with these metafields
|
|
671
|
+
([what runs](checkout.md#owners-and-scenarios)).
|
|
672
|
+
- `tax_rate`: a percentage, added to the discounted subtotal.
|
|
673
|
+
|
|
674
|
+
### Checkout scenarios
|
|
675
|
+
|
|
676
|
+
A scenario is a named setup of the local checkout: one file in `checkouts/`, named after the
|
|
677
|
+
scenario (`checkouts/b2b-seattle.json` is `b2b-seattle`). The home page of the checkout lists
|
|
678
|
+
them and opens a fresh checkout for each; `app function run --scenario <name>` runs the Functions
|
|
679
|
+
for one ([checkout](checkout.md#scenarios)). A scenario is a set of overrides on top of the
|
|
680
|
+
store data: what it does not set stays as the data says.
|
|
681
|
+
|
|
682
|
+
```json
|
|
683
|
+
{
|
|
684
|
+
"$schema": "../schema/checkout-scenario.schema.json",
|
|
685
|
+
"title": "B2B buyer at the Seattle location",
|
|
686
|
+
"description": "Four tees, a company customer, and a shipping rate for pallets.",
|
|
687
|
+
"session": {
|
|
688
|
+
"customer": "alex.morgan@example.com",
|
|
689
|
+
"company_location": "Northwind Seattle",
|
|
690
|
+
"country": "US",
|
|
691
|
+
"cart": {
|
|
692
|
+
"items": [{ "variant": "TEE-BLK-M", "quantity": 4 }],
|
|
693
|
+
"note": "Deliver to dock B",
|
|
694
|
+
"attributes": { "po_number": "PO-1042" }
|
|
695
|
+
}
|
|
696
|
+
},
|
|
697
|
+
"entered": {
|
|
698
|
+
"email": "alex.morgan@example.com",
|
|
699
|
+
"shipping_address": { "last_name": "Morgan", "address1": "2200 Alder Street", "city": "Seattle", "country_code": "US" },
|
|
700
|
+
"discount_codes": ["SAVE10"],
|
|
701
|
+
"delivery": "pallet",
|
|
702
|
+
"payment": "bank"
|
|
703
|
+
},
|
|
704
|
+
"language": "en",
|
|
705
|
+
"checkout": {
|
|
706
|
+
"shipping_rates": [{ "handle": "pallet", "title": "Pallet", "price": "45.00" }],
|
|
707
|
+
"discounts": [{ "function": "volume-discount", "code": "SAVE10", "classes": ["ORDER"] }]
|
|
708
|
+
},
|
|
709
|
+
"variant": "standard",
|
|
710
|
+
"extensions": ["volume-discount", "gift-message"]
|
|
711
|
+
}
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
Every field is optional.
|
|
715
|
+
|
|
716
|
+
- `title` and `description` are for the home page. The title defaults to the file name.
|
|
717
|
+
- `session` is a [session](#customers-and-sessions), applied on top of the `session` of the
|
|
718
|
+
store data: `customer`, `cart` (`items` with `variant`, `quantity`, `properties` and
|
|
719
|
+
`selling_plan`, `note`, `attributes`), `country` and `company_location`.
|
|
720
|
+
- `entered` is what the buyer already typed and chose: `email`, `phone`, `shipping_address`
|
|
721
|
+
(`first_name`, `last_name`, `company`, `address1`, `address2`, `city`, `country_code`,
|
|
722
|
+
`province_code`, `zip`, `phone`), `delivery` (the handle of a shipping rate), `payment` (the
|
|
723
|
+
handle of a payment method) and `discount_codes`. A code that no discount has is entered all
|
|
724
|
+
the same, and refused.
|
|
725
|
+
- `language` is a language of the store (`localization.languages`). The checkout opens under
|
|
726
|
+
its locale root (`/fr/checkouts/cn/...`).
|
|
727
|
+
- `checkout` overrides the [`checkout` section](#checkout) of the store data. Every field that is
|
|
728
|
+
present replaces the store's: `shipping_rates`, `payment_methods`, `discounts` (`[]` runs no
|
|
729
|
+
discount), `tax_rate`. `functions` replaces the settings of the Functions it names and keeps
|
|
730
|
+
those of the others; an entry without `enabled` keeps the store's owner of it.
|
|
731
|
+
- `variant` is the look: `standard` or `shop`. `?variant=` in the URL still wins.
|
|
732
|
+
- `extensions` lists the handles of the extensions (Functions and checkout UI extensions) that
|
|
733
|
+
run. The others of the app are left out. It narrows what `--extension` allows. The Functions it
|
|
734
|
+
names run whether or not the shop has an owner of them: a scenario that names none shows the
|
|
735
|
+
shop's checkout, where only those with an enabled owner run. A Function you add to the app
|
|
736
|
+
later is not in the `extensions` of the scenarios written before it: add its handle.
|
|
737
|
+
- `expect` is what the checkout must look like once the Functions ran, which `app check`
|
|
738
|
+
checks: the payment methods, express buttons, delivery options, discounts, lines and errors,
|
|
739
|
+
each with `includes`, `excludes` and `equals`, and exact `totals`
|
|
740
|
+
(`"expect": { "express_methods": { "excludes": ["paypal"] }, "totals": { "total": "96.00" } }`).
|
|
741
|
+
See [expected results](checkout.md#expected-results).
|
|
742
|
+
|
|
743
|
+
A scenario that does not validate is still listed, with its errors, and cannot be opened.
|
|
744
|
+
`validate` reports it with the file `checkouts/<name>.json` and a pointer into it
|
|
745
|
+
(`/session/cart/items/0/variant`). Files that are not `.json`, and folders, are ignored.
|
|
746
|
+
Scenarios are reloaded when the files change, like the rest of the data. The built-in demo
|
|
747
|
+
store has none: `npx local-storefront app init` writes a set.
|
|
748
|
+
|
|
749
|
+
## Localization
|
|
750
|
+
|
|
751
|
+
```json
|
|
752
|
+
{
|
|
753
|
+
"localization": {
|
|
754
|
+
"countries": [{ "iso_code": "US", "popular": true }, { "iso_code": "FR" }],
|
|
755
|
+
"languages": [{ "iso_code": "en" }, { "iso_code": "fr" }]
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
The first language is the primary one, served at `/`. The others are served under
|
|
761
|
+
`/<iso_code>/` (`/fr/products/blue-shirt`) and use the theme's `locales/<iso_code>.json`.
|
|
762
|
+
Names of countries default to built-in values. Without `countries`, the store
|
|
763
|
+
sells in one country: the shop's (`shop.address.country_code`), or `US`. The localization form
|
|
764
|
+
(`{% form 'localization' %}`) switches the session's country and language. A visitor who
|
|
765
|
+
selected no country is in the shop's (`shop.address.country_code`), the one of its primary
|
|
766
|
+
market, on the storefront as at the checkout; in the first country when the store does not sell
|
|
767
|
+
to the shop's.
|
|
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
|
+
|
|
782
|
+
`markets` groups countries the way Shopify's markets do. The checkout Functions read the
|
|
783
|
+
market of the buyer (`localization.market`) and of an address (`MailingAddress.market`):
|
|
784
|
+
|
|
785
|
+
```json
|
|
786
|
+
{
|
|
787
|
+
"localization": {
|
|
788
|
+
"countries": [{ "iso_code": "FR" }, { "iso_code": "DE" }, { "iso_code": "US" }],
|
|
789
|
+
"languages": [{ "iso_code": "en" }, { "iso_code": "fr" }, { "iso_code": "de" }],
|
|
790
|
+
"markets": [
|
|
791
|
+
{
|
|
792
|
+
"handle": "europe",
|
|
793
|
+
"countries": ["FR", "DE"],
|
|
794
|
+
"languages": ["fr", "de"],
|
|
795
|
+
"metafields": { "custom": { "tier": "gold" } }
|
|
796
|
+
}
|
|
797
|
+
]
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
A country in no market is a market of its own, named after the country (`france`, `united-states`).
|
|
803
|
+
An address in a country the store does not sell to has no market.
|
|
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
|
+
|
|
854
|
+
## Images and files
|
|
855
|
+
|
|
856
|
+
An image is a path inside `files/`, optionally with metadata:
|
|
857
|
+
|
|
858
|
+
```json
|
|
859
|
+
"products/tee-white.jpg"
|
|
860
|
+
{ "src": "products/tee-white.jpg", "alt": "White t-shirt", "width": 1600, "height": 2000 }
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
URLs keep the shape Shopify gives them but point to the local server, never to Shopify's CDN:
|
|
864
|
+
|
|
865
|
+
```text
|
|
866
|
+
{{ product.featured_image | image_url: width: 400, height: 400, crop: 'center' }}
|
|
867
|
+
→ //localhost:9292/cdn/shop/files/products/tee-white.jpg?crop=center&height=400&v=1651229318&width=400
|
|
868
|
+
```
|
|
869
|
+
|
|
870
|
+
The server resizes and crops on the fly with Shopify's parameters: `width`, `height`, `crop`
|
|
871
|
+
(`top`, `center`, `bottom`, `left`, `right`, or a region with `crop_left`, `crop_top`,
|
|
872
|
+
`crop_width`, `crop_height`), `pad_color`, `format` and `quality`. Images are never scaled up
|
|
873
|
+
beyond their own size, as on Shopify. The legacy `img_url` sizes
|
|
874
|
+
(`shirt_400x400_crop_center.jpg`) work too.
|
|
875
|
+
|
|
876
|
+
**The file does not have to exist.** When it is missing, the server draws a placeholder of the
|
|
877
|
+
declared `width` × `height` (1200 × 1200 when not declared) showing the file name, and answers
|
|
878
|
+
with the header `x-lsf-placeholder: 1`. Layout, aspect ratios and `srcset` are then the same as
|
|
879
|
+
with real images, so fixtures do not need to ship binaries. When the file exists, its real
|
|
880
|
+
size is read from it.
|
|
881
|
+
|
|
882
|
+
`files` gives metadata to files that are referenced by name from theme settings or metafields:
|
|
883
|
+
|
|
884
|
+
```json
|
|
885
|
+
{ "files": { "hero.jpg": { "alt": "Storefront", "width": 2400, "height": 1200 } } }
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
A setting of type `image_picker` holding `shopify://shop_images/hero.jpg` resolves to
|
|
889
|
+
`files/hero.jpg`.
|
|
890
|
+
|
|
891
|
+
Fonts picked with `font_picker` come from Shopify's font library, which is not available
|
|
892
|
+
offline. Put a font at `files/fonts/<file name>` to serve the real file; otherwise a blank font
|
|
893
|
+
is served so that browsers fall back to the theme's fallback stack without network errors.
|
|
894
|
+
|
|
895
|
+
## Metafields and metaobjects
|
|
896
|
+
|
|
897
|
+
Metafields are grouped by namespace, then key. A plain value gets its type inferred; spell out
|
|
898
|
+
`{ "type", "value" }` for anything else. References are written as handles or GIDs
|
|
899
|
+
([reference values](#reference-values)), files as paths inside `files/`.
|
|
900
|
+
|
|
901
|
+
```json
|
|
902
|
+
{
|
|
903
|
+
"metafields": {
|
|
904
|
+
"custom": {
|
|
905
|
+
"care": "Machine wash cold",
|
|
906
|
+
"rating": 5,
|
|
907
|
+
"organic": true,
|
|
908
|
+
"size_guide": { "type": "page_reference", "value": "size-guide" },
|
|
909
|
+
"related": { "type": "list.product_reference", "value": ["linen-overshirt"] },
|
|
910
|
+
"swatch": { "type": "color", "value": "#f4f1ea" },
|
|
911
|
+
"lookbook": { "type": "file_reference", "value": "lookbook.jpg" }
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
```
|
|
916
|
+
|
|
917
|
+
`{{ product.metafields.custom.care }}` and
|
|
918
|
+
`{{ product.metafields.custom.size_guide.value.title }}` then behave as on Shopify, including
|
|
919
|
+
`metafield_tag` and `metafield_text`.
|
|
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
|
+
|
|
938
|
+
A typed value is checked against its type, as Shopify checks it. `{"type": "number_integer",
|
|
939
|
+
"value": "two"}` is an `invalid_metafield` error, and so is a `weight` in `"kgs"` or a
|
|
940
|
+
`rating` outside its scale. A `weight`, `dimension` or `volume` takes the units Shopify lists
|
|
941
|
+
(`kilograms`, `centimeters`, `milliliters`...) and their short forms (`kg`, `cm`, `ml`). A
|
|
942
|
+
`language` is a locale code (`fr`, `pt-BR`). A type Shopify does not have is an
|
|
943
|
+
`unknown_metafield_type`. A reference that names nothing of the data is an
|
|
944
|
+
`unknown_reference` ([reference values](#reference-values)).
|
|
945
|
+
|
|
946
|
+
A `file_reference` is an image when its extension is one (`.jpg`, `.png`, `.webp`...) and a
|
|
947
|
+
video for `.mp4`, `.mov`, `.webm` and `.m4v`: `sources`, `preview_image`, `video_tag` and
|
|
948
|
+
`metafield_tag` work.
|
|
949
|
+
A video's poster is the `.jpg` of the same name (`looks/summer.jpg` for `looks/summer.mp4`),
|
|
950
|
+
and its size the one declared in `files` (1920×1080 otherwise). Any other file has a `url`.
|
|
951
|
+
|
|
952
|
+
Metaobjects are listed by type:
|
|
953
|
+
|
|
954
|
+
```json
|
|
955
|
+
{
|
|
956
|
+
"metaobjects": {
|
|
957
|
+
"designer": [{ "handle": "sam", "fields": { "name": "Sam", "portrait": { "type": "file_reference", "value": "sam.jpg" } } }]
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
```
|
|
961
|
+
|
|
962
|
+
and read with `{{ metaobjects.designer.sam.name }}` or
|
|
963
|
+
`{% for d in metaobjects.designer.values %}`.
|
|
964
|
+
|
|
965
|
+
A type whose theme has `templates/metaobject/<type>.json` (or `.liquid`) is a type with web
|
|
966
|
+
pages on Shopify: each entry is served at `/pages/<type>/<handle>`, the template sees it as
|
|
967
|
+
`metaobject`, and `metaobject.system.url` is that URL. Without the template the entry has no
|
|
968
|
+
page and `system.url` is blank. `"template_suffix": "alt"` on an entry renders it with
|
|
969
|
+
`templates/metaobject/<type>.alt.json`, and `?view=alt` does the same for one request. The page
|
|
970
|
+
title is the entry's `title` or `name` field, or its handle. `shopify://metaobjects/<type>/<handle>`
|
|
971
|
+
in a `url` setting leads to the page.
|
|
972
|
+
|
|
973
|
+
The Functions of the [local checkout](checkout.md) read the same metaobjects as
|
|
974
|
+
`shop.metaobject(handle: {type: "designer", handle: "sam"})`, with their `field(key:)`.
|
|
975
|
+
|
|
976
|
+
The types an app owns are written `$app:<type>` (`"$app:author"`), as its reserved metafield
|
|
977
|
+
namespaces are (`$app:config`). Shopify writes them in full, `app--<app id>--author`, which a
|
|
978
|
+
theme's Liquid uses: `metaobjects['app--1234--author']` and `metaobjects['$app:author']` both
|
|
979
|
+
find them, whatever the id, since the store data does not know it.
|
|
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
|
+
|
|
1073
|
+
|
|
1074
|
+
## Time and theme settings
|
|
1075
|
+
|
|
1076
|
+
- `now` freezes the clock: `"now": "2025-01-15T10:00:00Z"`. `{{ 'now' | date: ... }}`, sale
|
|
1077
|
+
countdowns and "new" badges then render the same on every run. As on Shopify, where each
|
|
1078
|
+
`'now'` is a new reading of the clock, each `'now'` of a render is one nanosecond after the
|
|
1079
|
+
previous one: `{{ 'now' | date: '%N' }}` gives `000000000`, then `000000001`, and so on, so
|
|
1080
|
+
ids built from it differ. Without `now`, `'now'` reads the real clock each time.
|
|
1081
|
+
- `theme_settings` overrides values of `config/settings_data.json` by setting id, without
|
|
1082
|
+
touching the theme: `"theme_settings": { "cart_type": "drawer" }`. Each id and value is
|
|
1083
|
+
checked against the theme's `config/settings_schema.json`: an id the theme does not have is
|
|
1084
|
+
an `unknown_setting`, and a value its setting does not take (an option a `select` does not
|
|
1085
|
+
have, a `range` out of bounds, a `checkbox` that is not `true` or `false`) an
|
|
1086
|
+
`invalid_setting`. Without a theme, `validate` cannot check them, and a warning says so.
|
|
1087
|
+
|
|
1088
|
+
## The app
|
|
1089
|
+
|
|
1090
|
+
With `serve --app`, `app` is the app as the store has it installed, for its theme app
|
|
1091
|
+
extensions:
|
|
1092
|
+
|
|
1093
|
+
```json
|
|
1094
|
+
{
|
|
1095
|
+
"app": {
|
|
1096
|
+
"metafields": { "reviews": { "badge_enabled": true } },
|
|
1097
|
+
"embeds": { "chat-bubble": true, "reviews-loader": { "settings": { "api_key": "test-key" } } }
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
```
|
|
1101
|
+
|
|
1102
|
+
- `metafields` are its app-data metafields, by namespace then key, written as other
|
|
1103
|
+
metafields. The Liquid of its blocks reads them as `app.metafields.reviews.badge_enabled`,
|
|
1104
|
+
and the `available_if` of a block tests them.
|
|
1105
|
+
- `embeds` turns its app embed blocks on or off, by the name of their file in `blocks/`: `true`,
|
|
1106
|
+
`false`, or `{"settings": {...}}` to turn one on with these settings. What it does not name
|
|
1107
|
+
stays as `config/settings_data.json` has it.
|
|
1108
|
+
|
|
1109
|
+
## Error codes
|
|
1110
|
+
|
|
1111
|
+
| Code | Meaning |
|
|
1112
|
+
|---|---|
|
|
1113
|
+
| `invalid_json` | The file is not valid JSON. The hint gives the line and column. |
|
|
1114
|
+
| `unreadable_file` | The file could not be 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. |
|
|
1116
|
+
| `classic_customer_accounts` | A warning: `shop.customer_accounts` or a customer's `password`, which classic customer accounts had. The field is ignored. |
|
|
1117
|
+
| `missing_field` | A required field is absent. |
|
|
1118
|
+
| `wrong_type` | A value of the wrong JSON type. |
|
|
1119
|
+
| `invalid_value`, `invalid_format` | A value outside what the field accepts, or not in its format. Money as a JSON float (`19.99`) is an `invalid_value`, and a string that is not an amount (`"19.999"`) an `invalid_format`; the hint says what the field takes. |
|
|
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. |
|
|
1121
|
+
| `duplicate_handle`, `duplicate_id`, `duplicate_email`, `duplicate_menu`, `duplicate_variant`, `duplicate_gift_card` | Two entities with the same identity. |
|
|
1122
|
+
| `duplicate_section` | `shop`, `localization`, `session`, `checkout` or `now` defined in two files. |
|
|
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. |
|
|
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. |
|
|
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. |
|
|
1130
|
+
| `unknown_return_line` | A return of an order names a variant that is not a line of the order. |
|
|
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. |
|
|
1132
|
+
| `invalid_throttle` | The `throttle` of a session sent to `PUT /__lsf/session` is not one `--throttle` takes. |
|
|
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. |
|
|
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. |
|
|
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. |
|
|
1137
|
+
| `unchecked_theme_settings` (warning) | The data has `theme_settings` and there is no theme to check them against. |
|
|
1138
|
+
| `invalid_color`, `empty_swatch` | A swatch whose color is not a CSS color, or that has neither a color nor an image. |
|
|
1139
|
+
| `missing_price` | A product without a price on itself or on its variants. |
|
|
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. |
|
|
1141
|
+
| `media_conflict`, `missing_sources`, `missing_external_video` | Incomplete or contradictory product media. |
|
|
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. |
|
|
1145
|
+
| `missing_locale_file` (warning) | A language the theme has no `locales/<code>.json` for. |
|
|
1146
|
+
| `gift_card_balance` (warning) | A gift card whose balance is above its initial value. |
|
|
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. |
|
|
1148
|
+
|
|
1149
|
+
`path` is always a JSON pointer into `file`, so the value to fix can be located mechanically.
|
|
1150
|
+
|
|
1151
|
+
An entity with a value that cannot be read (a `wrong_type`, an `invalid_value`, an
|
|
1152
|
+
`invalid_format`, a `missing_field`) is left out. The rest of its file is still read, with its
|
|
1153
|
+
references, so one run reports every problem. Each reference to the entity left out is an error
|
|
1154
|
+
too (`unknown_product`, `unknown_variant`, `unknown_customer`, `unknown_reference`), and its
|
|
1155
|
+
hint says so: `the product "a" is left out because products/a.json has errors`. Fix that file,
|
|
1156
|
+
and these errors go away with it.
|
|
1157
|
+
|
|
1158
|
+
## Field reference
|
|
1159
|
+
|
|
1160
|
+
Every field of every type is listed in [data-reference.md](data-reference.md), which is
|
|
1161
|
+
generated from the JSON Schema the validator uses.
|