local-storefront 0.2.0 → 0.3.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,943 @@
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.
124
+ - **HTML** fields (`description`, `content`, `body`) are inserted as written.
125
+
126
+ ## Products
127
+
128
+ The smallest product:
129
+
130
+ ```json
131
+ { "title": "Blue shirt", "price": "19.99" }
132
+ ```
133
+
134
+ It gets a single default variant, like a product without options in the Shopify admin. A
135
+ product with options lists its variants; each gives one value per option:
136
+
137
+ ```json
138
+ {
139
+ "title": "Organic Cotton T-Shirt",
140
+ "vendor": "Local Supply Co.",
141
+ "type": "Shirts",
142
+ "tags": ["cotton", "new"],
143
+ "options": ["Color", "Size"],
144
+ "price": "28.00",
145
+ "compare_at_price": "35.00",
146
+ "images": [
147
+ { "src": "products/tee-white.jpg", "alt": "White t-shirt", "width": 1600, "height": 2000 },
148
+ "products/tee-black.jpg"
149
+ ],
150
+ "variants": [
151
+ { "options": ["White", "S"], "sku": "TEE-WHT-S", "inventory_quantity": 12, "image": "products/tee-white.jpg" },
152
+ { "options": ["White", "M"], "sku": "TEE-WHT-M", "inventory_quantity": 0 },
153
+ { "options": ["Black", "S"], "sku": "TEE-BLK-S", "price": "30.00", "available": false }
154
+ ],
155
+ "collections": ["apparel"],
156
+ "metafields": { "custom": { "care": "Machine wash cold" } }
157
+ }
158
+ ```
159
+
160
+ - `price` and `compare_at_price` on the product apply to the variants that do not set their own.
161
+ - **Availability** follows Shopify's rules. A variant with `inventory_quantity` is tracked: it
162
+ is available while the quantity is above zero, or always with
163
+ `"inventory_policy": "continue"`. A variant without `inventory_quantity` is not tracked and
164
+ always available. `available` forces the answer either way. `"available": false` alone also
165
+ 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
+ `"available": false` with an `inventory_quantity` above 0 is a warning
168
+ (`unavailable_in_stock`): Shopify never has both. lsf keeps the variant unavailable with its
169
+ stock, which Horizon shows as "Unavailable".
170
+ - `options` can spell out the order of the values:
171
+ `{ "name": "Size", "values": ["S", "M", "L"] }`. A value can carry a [swatch](#swatches).
172
+ - `selling_plan_groups` and `requires_selling_plan` sell the product by subscription: see
173
+ [selling plans](#selling-plans).
174
+ - A variant's `store_availabilities` says where it can be picked up: see
175
+ [store pickup](#store-pickup).
176
+ - Use `media` instead of `images` to mix images with videos, external videos and 3D models.
177
+ - `recommendations` lists the handles returned by product recommendations; by default they are
178
+ other products of the same collections.
179
+ - `template_suffix` selects an alternate template (`templates/product.<suffix>.json`).
180
+ - `status` (`active`, `draft`, `archived`) and `published` (on the Online Store, `true` by
181
+ default) say whether the product is on the storefront: only an active, published product is.
182
+ The others have no page, are in no collection, cannot be bought, and what names them (a
183
+ collection, a menu, an order) leaves them out without an error; the Admin API lists them with
184
+ their status.
185
+
186
+ ## Collections
187
+
188
+ ```json
189
+ {
190
+ "collections": [
191
+ { "title": "Apparel", "products": ["organic-cotton-t-shirt", "linen-overshirt"] },
192
+ { "title": "Sale", "rules": [{ "column": "tag", "condition": "sale" }] },
193
+ {
194
+ "title": "Under $30",
195
+ "sort_order": "price-ascending",
196
+ "rules": [{ "column": "price", "relation": "less_than", "condition": "3000" }]
197
+ }
198
+ ]
199
+ }
200
+ ```
201
+
202
+ A product belongs to a collection when the collection lists it in `products`, when the product
203
+ lists the collection in its own `collections`, or when it matches the collection's `rules`
204
+ (all of them, or any of them with `"disjunctive": true`).
205
+
206
+ `collections.all` always exists and contains every product, sorted by title as on Shopify.
207
+ Define a collection with the handle `all` only to change its title or order.
208
+
209
+ Storefront filtering (`collection.filters`, `?filter.v.availability=1`,
210
+ `?filter.v.price.gte=10`, `?filter.p.vendor=...`, `?filter.v.option.color=...`), sorting
211
+ (`?sort_by=`) and tag URLs (`/collections/<handle>/<tag>`) work from this data without any
212
+ extra configuration.
213
+
214
+ ## Selling plans
215
+
216
+ A selling plan is a way of buying a product other than once: a subscription, a pre-order.
217
+ Plans come in groups, which is what a subscription app creates.
218
+
219
+ ```json
220
+ {
221
+ "selling_plan_groups": [
222
+ {
223
+ "name": "Subscribe and save",
224
+ "options": ["Delivery frequency"],
225
+ "selling_plans": [
226
+ {
227
+ "name": "Deliver every month, 10% off",
228
+ "options": ["Every month"],
229
+ "price_adjustments": [{ "value_type": "percentage", "value": 10 }]
230
+ },
231
+ {
232
+ "name": "Deliver every 3 months",
233
+ "options": ["Every 3 months"]
234
+ }
235
+ ],
236
+ "products": ["organic-cotton-t-shirt"]
237
+ }
238
+ ]
239
+ }
240
+ ```
241
+
242
+ - A product is sold with a group when the group lists it in `products`, or when the product
243
+ names the group: `"selling_plan_groups": ["Subscribe and save"]`. The plans apply to every
244
+ variant of the product.
245
+ - `"requires_selling_plan": true` on a product means it cannot be bought once.
246
+ - A **price adjustment** has a `value_type`: `percentage` (`value` percent off), `fixed_amount`
247
+ (`value` off, an amount of money) or `price` (`value` is the price). A plan has at most two:
248
+ the first one gives the price, the second one is what later orders cost. Without any, the
249
+ plan costs what the variant costs.
250
+ - `options` of the group are the names of what the plans differ by, and each plan gives one
251
+ value per option. They default to one option, `Delivery frequency`, whose value is the
252
+ plan's name.
253
+ - `checkout_charge` is what is paid at checkout, for a deposit:
254
+ `{ "value_type": "percentage", "value": 50 }`. It defaults to the whole price. The checkout
255
+ of an [app](checkout.md#deferred-payments) charges only that today, and shows the rest as due
256
+ later.
257
+ - `recurring_deliveries` defaults to `true`. Ids are generated and stable; set `id` on a plan
258
+ to reproduce a Shopify id.
259
+
260
+ In Liquid, `product.selling_plan_groups`, `variant.selling_plan_allocations` and
261
+ `product.selected_or_first_available_selling_plan_allocation` are filled. `?selling_plan=<id>`
262
+ selects a plan (`product.selected_selling_plan`, `selling_plan.selected`), and with `?variant=`
263
+ an allocation (`product.selected_selling_plan_allocation`). `/products/<handle>.js` and the
264
+ `json` filter list the groups and the allocations as Shopify does.
265
+
266
+ In the cart, `selling_plan` is accepted by `/cart/add.js` and `/cart/change.js`. The line then
267
+ has the plan's price and its `selling_plan_allocation`, and stays separate from the same
268
+ variant bought once. A product is refused with a plan it is not sold with, and without one
269
+ when it requires one.
270
+
271
+ ## Store pickup
272
+
273
+ `locations` are the places the store stocks its products at.
274
+
275
+ ```json
276
+ {
277
+ "locations": [
278
+ {
279
+ "name": "Portland flagship",
280
+ "address": {
281
+ "address1": "12 Maple Street",
282
+ "city": "Portland",
283
+ "province_code": "OR",
284
+ "zip": "97204",
285
+ "country_code": "US",
286
+ "phone": "+1 503 555 0100"
287
+ },
288
+ "pick_up_time": "Usually ready in 2 hours",
289
+ "latitude": 45.5202,
290
+ "longitude": -122.6742
291
+ },
292
+ { "name": "Seattle warehouse", "pick_up_enabled": false }
293
+ ]
294
+ }
295
+ ```
296
+
297
+ - `pick_up_enabled` and `physical_storefront` default to `true`, `pick_up_time` to
298
+ `Usually ready in 24 hours`.
299
+ - Every location stocks every variant, and a variant is in stock there when it is available. A
300
+ variant that lists `store_availabilities` is stocked by those locations only, each with its
301
+ own stock:
302
+ `"store_availabilities": { "Portland flagship": true, "Seattle warehouse": false }`.
303
+
304
+ In Liquid, `variant.store_availabilities` lists a `store_availability` per location, with its
305
+ `location` (`name`, `address`, `latitude`, `longitude`, `metafields`). As on Shopify, only the
306
+ selected variant and the first available one have them: the other variants of a product have
307
+ an empty list.
308
+
309
+ `/variants/<id>?section_id=<section>` renders a section for a variant, which the section reads
310
+ as `product_variant`. Themes fetch that URL to show pickup availability. Without `section_id`,
311
+ the URL leads to the product with the variant selected.
312
+
313
+ ## Swatches
314
+
315
+ A swatch shows an option value as a color or an image instead of its name. `swatches` gives
316
+ them by the name of the value, for the whole store:
317
+
318
+ ```json
319
+ {
320
+ "swatches": {
321
+ "White": "#f4f1ea",
322
+ "Sage": "rgb(156, 175, 136)",
323
+ "Floral": { "image": "swatches/floral.jpg" },
324
+ "Sand": { "color": "#d8c7a8", "image": "swatches/sand.jpg" }
325
+ }
326
+ }
327
+ ```
328
+
329
+ Every option value with one of these names gets the swatch, whatever the product and the
330
+ option. A product can give its own to a value, in the long form of `options`:
331
+
332
+ ```json
333
+ {
334
+ "options": [
335
+ { "name": "Color", "values": ["White", { "name": "Sunset", "swatch": "#ff7a00" }] },
336
+ "Size"
337
+ ]
338
+ }
339
+ ```
340
+
341
+ In Liquid, `product_option_value.swatch` has a `color` and an `image`, and is `nil` for a
342
+ value without a swatch. In collection filters, a filter whose values have swatches has the
343
+ `swatch` presentation, and each `filter_value.swatch` is filled.
344
+
345
+ ## Pages, blogs and articles
346
+
347
+ ```json
348
+ {
349
+ "pages": [
350
+ { "title": "About", "content": "<p>Since 2012.</p>" },
351
+ { "title": "Contact", "template_suffix": "contact" }
352
+ ],
353
+ "blogs": [
354
+ {
355
+ "title": "Journal",
356
+ "comments_enabled": true,
357
+ "articles": [
358
+ {
359
+ "title": "How to care for linen",
360
+ "author": "Sam",
361
+ "tags": ["care"],
362
+ "published_at": "2024-05-01",
363
+ "image": { "src": "blog/linen.jpg", "width": 1600, "height": 900 },
364
+ "excerpt": "<p>Linen gets better with every wash.</p>",
365
+ "content": "<p>Wash cold, hang dry.</p>",
366
+ "comments": [{ "author": "Alex", "content": "Great tips." }]
367
+ }
368
+ ]
369
+ }
370
+ ]
371
+ }
372
+ ```
373
+
374
+ ## Menus
375
+
376
+ `menus` maps a menu handle (`main-menu`, `footer`, ...) to its links. A link points at a
377
+ resource with one of `url`, `collection`, `product`, `page`, `blog`, `article`
378
+ (`"<blog>/<article>"`) or `policy`; its URL, `link.type` and `link.object` follow from it.
379
+ `link.active` and `link.child_active` follow from the page being rendered.
380
+
381
+ ```json
382
+ {
383
+ "menus": {
384
+ "main-menu": {
385
+ "links": [
386
+ { "title": "Home", "url": "/" },
387
+ {
388
+ "title": "Shop",
389
+ "collection": "all",
390
+ "links": [{ "title": "Apparel", "collection": "apparel" }]
391
+ },
392
+ { "title": "About", "page": "about" }
393
+ ]
394
+ }
395
+ }
396
+ }
397
+ ```
398
+
399
+ ## The shop
400
+
401
+ Every field of `shop` is optional.
402
+
403
+ ```json
404
+ {
405
+ "shop": {
406
+ "name": "Local Supply Co.",
407
+ "currency": "EUR",
408
+ "money_format": "{{amount_with_comma_separator}} €",
409
+ "timezone": "Europe/Paris",
410
+ "policies": { "refund_policy": { "body": "<p>30 days.</p>" } },
411
+ "enabled_payment_types": ["visa", "master", "paypal"]
412
+ }
413
+ }
414
+ ```
415
+
416
+ - `money_format` and `money_with_currency_format` default to the usual format of the currency.
417
+ - `timezone` is the zone dates are displayed in by the `date` filter.
418
+ - `password` is the password the `/password` page accepts. It is `password` when you do not
419
+ set it. The storefront is never locked: the page is always there, and nothing leads to it.
420
+ - Each policy that is set gets a page under `/policies/`.
421
+
422
+ ## Customers and sessions
423
+
424
+ A **customer** exists in the store. A **session** is one browser's state: who is logged in,
425
+ what is in the cart, which country is selected.
426
+
427
+ ```json
428
+ {
429
+ "customers": [
430
+ {
431
+ "email": "jane.doe@example.com",
432
+ "first_name": "Jane",
433
+ "last_name": "Doe",
434
+ "tags": ["vip"],
435
+ "addresses": [{ "address1": "12 Maple Street", "city": "Portland", "country_code": "US" }],
436
+ "orders": [{ "line_items": [{ "variant": "TEE-WHT-S", "quantity": 2 }] }]
437
+ }
438
+ ],
439
+ "session": {
440
+ "customer": "jane.doe@example.com",
441
+ "cart": { "items": [{ "variant": "TEE-WHT-S", "quantity": 1 }], "note": "Gift wrap" },
442
+ "country": "FR"
443
+ }
444
+ }
445
+ ```
446
+
447
+ `session` is what every new browser session starts with. Leave it out for an anonymous visitor
448
+ with an empty cart. A variant is referenced by its SKU, by its id, or by a product handle
449
+ (meaning the product's first variant).
450
+
451
+ A cart item bought with a [selling plan](#selling-plans) names the plan, or gives its id:
452
+ `{ "variant": "TEE-WHT-S", "selling_plan": "Deliver every month, 10% off" }`.
453
+
454
+ `session.customer` is the email of a customer, `"default"` for the first customer of the data,
455
+ or `"none"`. When the data has no customer at all, there is still one to log in as:
456
+ `customer@example.com`.
457
+
458
+ ### Logging in
459
+
460
+ Customer accounts are Shopify's new customer accounts: they are hosted by Shopify and are not
461
+ part of a theme. Here, `/account` is a page of local-storefront that stands in for them. It
462
+ lists the customers of the data: click one to be logged in as them, or "Nobody" to be logged
463
+ out. Every account link of a theme (`routes.account_login_url`, `routes.storefront_login_url`,
464
+ ...) leads to that page, and so do its login and register forms.
465
+
466
+ Classic customer accounts are not simulated: the theme's `templates/customers` are never
467
+ rendered (`serve` and `check` say so), and customers have no password. Data that still has
468
+ `shop.customer_accounts` or a customer's `password` is read as before: those fields are ignored,
469
+ with a warning to remove them.
470
+
471
+ `npx local-storefront serve --customer <email>` starts every visitor logged in as that customer.
472
+ It also accepts `default` and `none`.
473
+
474
+ At the [checkout of an app](checkout.md#log-in-as-a-customer), the customers are the login
475
+ profiles: "Log in" in the Contact section lists them, and picking one logs the checkout (and the
476
+ session) in as them, with their saved addresses (the first one is the default) and, for a B2B
477
+ customer, the locations they buy for. Give the data a customer of each kind you test: a new one
478
+ (no orders, no addresses), a returning one (`addresses`, `orders`, `tags`, `metafields`), a B2B
479
+ buyer with several `company_locations`, a customer in another country. The demo store has one of
480
+ each.
481
+
482
+ ### B2B customers
483
+
484
+ A B2B customer buys for a **company**, at one of its **locations**. Describe the company, then
485
+ name it in the customer:
486
+
487
+ ```json
488
+ {
489
+ "companies": [
490
+ {
491
+ "name": "Northwind Hotels",
492
+ "external_id": "NW-001",
493
+ "metafields": { "custom": { "payment_terms": "Net 30" } },
494
+ "locations": [
495
+ {
496
+ "name": "Northwind Portland",
497
+ "tax_registration_id": "93-1234567",
498
+ "shipping_address": { "address1": "815 Harbor Way", "city": "Portland", "country_code": "US" },
499
+ "metafields": { "custom": { "delivery_notes": "Loading dock B" } }
500
+ },
501
+ { "name": "Northwind Seattle" }
502
+ ]
503
+ }
504
+ ],
505
+ "customers": [
506
+ {
507
+ "email": "alex.morgan@example.com",
508
+ "first_name": "Alex",
509
+ "company": "Northwind Hotels",
510
+ "metafields": { "custom": { "job_title": "Purchasing manager" } }
511
+ }
512
+ ]
513
+ }
514
+ ```
515
+
516
+ - The customer can buy for every location of the company. List some in `company_locations` to
517
+ restrict them: `"company_locations": ["Northwind Seattle"]`.
518
+ - They start at the first location. `session.company_location` names another one.
519
+ - In Liquid, `customer.b2b?` is true, and `customer.current_company`,
520
+ `customer.current_location` and `customer.company_available_locations` are filled.
521
+ - `location.url_to_set_as_current` changes the location, as on Shopify.
522
+ - The [local checkout](checkout.md) shows what Shopify's Functions read of a B2B buyer
523
+ (`purchasingCompany`), and you can set it. A company has `created_at` and `updated_at`, a
524
+ location the same plus `locale` (`"fr"`), `orders_count` and `total_spent` (`"1250.00"`). The
525
+ customer is the contact of their company: `company_contact` holds their `title`, `locale`,
526
+ `created_at` and `updated_at`.
527
+
528
+ ```json
529
+ {
530
+ "companies": [
531
+ {
532
+ "name": "Northwind Hotels",
533
+ "created_at": "2023-06-01",
534
+ "locations": [
535
+ { "name": "Northwind Portland", "locale": "en", "orders_count": 12, "total_spent": "8450.00" }
536
+ ]
537
+ }
538
+ ],
539
+ "customers": [
540
+ {
541
+ "email": "alex.morgan@example.com",
542
+ "company": "Northwind Hotels",
543
+ "company_contact": { "title": "Purchasing manager", "locale": "en" }
544
+ }
545
+ ]
546
+ }
547
+ ```
548
+
549
+ What is not set has a value: the dates are 2024-01-01 12:00 UTC plus one day per company,
550
+ the dates of a location and of a contact are those of their company (`updated_at` those of
551
+ `created_at`), the language is empty, and a location has no order and has spent nothing. The
552
+ orders of the customers are not counted in `orders_count`: it is what you write.
553
+
554
+ Catalogs are not simulated: a B2B customer sees the same products and prices as everyone.
555
+
556
+ Tests usually set the session per test rather than in the files, by sending it to the running
557
+ server. The body is a session, optionally with a `data` object: a data file applied on top of
558
+ the files on disk for that session only.
559
+
560
+ ```bash
561
+ curl -X PUT http://127.0.0.1:9292/__lsf/session \
562
+ -H 'x-lsf-session: my-test' -H 'content-type: application/json' \
563
+ -d '{"customer": "jane.doe@example.com", "data": {"shop": {"name": "Another name"}}}'
564
+ ```
565
+
566
+ A person can do the same from a browser: `/__lsf` lists the customers, and
567
+ `/__lsf/login?customer=<email>` logs the browser in as one of them (`default` and `none` work
568
+ too).
569
+
570
+ A wrong body is answered with `422` and the same diagnostics as
571
+ `npx local-storefront validate --format json`.
572
+
573
+ ## Gift cards
574
+
575
+ Each gift card has a page rendered by `templates/gift_card.liquid`. Its URL is listed by
576
+ `npx local-storefront routes`.
577
+
578
+ ```json
579
+ {
580
+ "gift_cards": [
581
+ { "code": "WCGX7X97G74JGDGC", "initial_value": "50.00", "balance": "32.50", "expires_on": "2030-12-31" }
582
+ ]
583
+ }
584
+ ```
585
+
586
+ ## Checkout
587
+
588
+ The `checkout` section sets up the local checkout of a Shopify app (`npx local-storefront
589
+ app dev --app <dir>`, see [checkout](checkout.md)). Every field has a default, and the section
590
+ changes nothing for a theme.
591
+
592
+ ```json
593
+ {
594
+ "checkout": {
595
+ "shipping_rates": [{ "handle": "standard", "title": "Standard", "price": "4.90" }],
596
+ "payment_methods": [{ "handle": "card", "name": "Credit card" }],
597
+ "discounts": [
598
+ {
599
+ "function": "volume-discount",
600
+ "title": "Volume",
601
+ "classes": ["PRODUCT"],
602
+ "metafields": { "$app.function-configuration": { "type": "json", "value": { "percentage": 10 } } }
603
+ }
604
+ ],
605
+ "functions": { "delivery-rules": { "enabled": true, "metafields": { "$app.function-configuration": { "type": "json", "value": {} } } } },
606
+ "tax_rate": 20
607
+ }
608
+ }
609
+ ```
610
+
611
+ - `shipping_rates`: the delivery options offered to every address. Without it, one: "Standard"
612
+ at 5.00.
613
+ - `payment_methods`: without it, "Credit card", "Cash on Delivery" and "Bank Deposit".
614
+ `placements` says where a method is offered, which payment customization Functions read:
615
+ `["PAYMENT_METHOD"]` (the list of the checkout, the default) and/or `["ACCELERATED_CHECKOUT"]`
616
+ (the express buttons). The page shows the methods of `PAYMENT_METHOD` in its list, and those of
617
+ `ACCELERATED_CHECKOUT` as express buttons above the contact section, once the payment
618
+ customizations ran (one can hide a method in one placement and leave it in the other). PayPal
619
+ with PayPal Express on is in both.
620
+ - `discounts`: each one uses a discount Function of the app by its handle. A `code` makes it a
621
+ code discount; without one it is automatic. `classes` (`PRODUCT`, `ORDER`, `SHIPPING`) says
622
+ what it may reduce, all three by default. `metafields` are those of the discount, by
623
+ `"namespace.key"`, in the same shorthand as other metafields. `id` is the number of the id
624
+ its Function reads (`gid://shopify/DiscountAutomaticNode/<id>`), its position in the list from
625
+ 1 by default.
626
+ - `functions`: the other Functions (cart transform, delivery or payment customization,
627
+ validation), by the handle of the Function: `metafields`, their settings, and `enabled`, whether
628
+ the shop has an owner of it (the payment customization, … the app creates) and it is on. As on
629
+ Shopify, the shop's checkout runs such a Function only through an enabled owner: `true` runs
630
+ it, `false` is an owner turned off, and without `enabled` the shop has none (as after an
631
+ install, until the app creates one through the Admin API). A scenario that names the Function
632
+ in `extensions` runs it anyway, with these metafields
633
+ ([what runs](checkout.md#owners-and-scenarios)).
634
+ - `tax_rate`: a percentage, added to the discounted subtotal.
635
+
636
+ ### Checkout scenarios
637
+
638
+ A scenario is a named setup of the local checkout: one file in `checkouts/`, named after the
639
+ scenario (`checkouts/b2b-seattle.json` is `b2b-seattle`). The home page of the checkout lists
640
+ them and opens a fresh checkout for each; `app function run --scenario <name>` runs the Functions
641
+ for one ([checkout](checkout.md#scenarios)). A scenario is a set of overrides on top of the
642
+ store data: what it does not set stays as the data says.
643
+
644
+ ```json
645
+ {
646
+ "$schema": "../schema/checkout-scenario.schema.json",
647
+ "title": "B2B buyer at the Seattle location",
648
+ "description": "Four tees, a company customer, and a shipping rate for pallets.",
649
+ "session": {
650
+ "customer": "alex.morgan@example.com",
651
+ "company_location": "Northwind Seattle",
652
+ "country": "US",
653
+ "cart": {
654
+ "items": [{ "variant": "TEE-BLK-M", "quantity": 4 }],
655
+ "note": "Deliver to dock B",
656
+ "attributes": { "po_number": "PO-1042" }
657
+ }
658
+ },
659
+ "entered": {
660
+ "email": "alex.morgan@example.com",
661
+ "shipping_address": { "last_name": "Morgan", "address1": "2200 Alder Street", "city": "Seattle", "country_code": "US" },
662
+ "discount_codes": ["SAVE10"],
663
+ "delivery": "pallet",
664
+ "payment": "bank"
665
+ },
666
+ "language": "en",
667
+ "checkout": {
668
+ "shipping_rates": [{ "handle": "pallet", "title": "Pallet", "price": "45.00" }],
669
+ "discounts": [{ "function": "volume-discount", "code": "SAVE10", "classes": ["ORDER"] }]
670
+ },
671
+ "variant": "standard",
672
+ "extensions": ["volume-discount", "gift-message"]
673
+ }
674
+ ```
675
+
676
+ Every field is optional.
677
+
678
+ - `title` and `description` are for the home page. The title defaults to the file name.
679
+ - `session` is a [session](#customers-and-sessions), applied on top of the `session` of the
680
+ store data: `customer`, `cart` (`items` with `variant`, `quantity`, `properties` and
681
+ `selling_plan`, `note`, `attributes`), `country` and `company_location`.
682
+ - `entered` is what the buyer already typed and chose: `email`, `phone`, `shipping_address`
683
+ (`first_name`, `last_name`, `company`, `address1`, `address2`, `city`, `country_code`,
684
+ `province_code`, `zip`, `phone`), `delivery` (the handle of a shipping rate), `payment` (the
685
+ handle of a payment method) and `discount_codes`. A code that no discount has is entered all
686
+ the same, and refused.
687
+ - `language` is a language of the store (`localization.languages`). The checkout opens under
688
+ its locale root (`/fr/checkouts/cn/...`).
689
+ - `checkout` overrides the [`checkout` section](#checkout) of the store data. Every field that is
690
+ present replaces the store's: `shipping_rates`, `payment_methods`, `discounts` (`[]` runs no
691
+ discount), `tax_rate`. `functions` replaces the settings of the Functions it names and keeps
692
+ those of the others; an entry without `enabled` keeps the store's owner of it.
693
+ - `variant` is the look: `standard` or `shop`. `?variant=` in the URL still wins.
694
+ - `extensions` lists the handles of the extensions (Functions and checkout UI extensions) that
695
+ run. The others of the app are left out. It narrows what `--extension` allows. The Functions it
696
+ names run whether or not the shop has an owner of them: a scenario that names none shows the
697
+ shop's checkout, where only those with an enabled owner run. A Function you add to the app
698
+ later is not in the `extensions` of the scenarios written before it: add its handle.
699
+ - `expect` is what the checkout must look like once the Functions ran, which `app check`
700
+ checks: the payment methods, express buttons, delivery options, discounts, lines and errors,
701
+ each with `includes`, `excludes` and `equals`, and exact `totals`
702
+ (`"expect": { "express_methods": { "excludes": ["paypal"] }, "totals": { "total": "96.00" } }`).
703
+ See [expected results](checkout.md#expected-results).
704
+
705
+ A scenario that does not validate is still listed, with its errors, and cannot be opened.
706
+ `validate` reports it with the file `checkouts/<name>.json` and a pointer into it
707
+ (`/session/cart/items/0/variant`). Files that are not `.json`, and folders, are ignored.
708
+ Scenarios are reloaded when the files change, like the rest of the data. The built-in demo
709
+ store has none: `npx local-storefront app init` writes a set.
710
+
711
+ ## Localization
712
+
713
+ ```json
714
+ {
715
+ "localization": {
716
+ "countries": [{ "iso_code": "US", "popular": true }, { "iso_code": "FR" }],
717
+ "languages": [{ "iso_code": "en" }, { "iso_code": "fr" }]
718
+ }
719
+ }
720
+ ```
721
+
722
+ The first language is the primary one, served at `/`. The others are served under
723
+ `/<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
725
+ sells in one country: the shop's (`shop.address.country_code`), or `US`. The localization form
726
+ (`{% form 'localization' %}`) switches the session's country and language. A visitor who
727
+ selected no country is in the shop's (`shop.address.country_code`), the one of its primary
728
+ market, on the storefront as at the checkout; in the first country when the store does not sell
729
+ to the shop's.
730
+
731
+ `markets` groups countries the way Shopify's markets do. The checkout Functions read the
732
+ market of the buyer (`localization.market`) and of an address (`MailingAddress.market`):
733
+
734
+ ```json
735
+ {
736
+ "localization": {
737
+ "countries": [{ "iso_code": "FR" }, { "iso_code": "DE" }, { "iso_code": "US" }],
738
+ "markets": [
739
+ { "handle": "europe", "countries": ["FR", "DE"], "metafields": { "custom": { "tier": "gold" } } }
740
+ ]
741
+ }
742
+ }
743
+ ```
744
+
745
+ A country in no market is a market of its own, named after the country (`france`, `united-states`).
746
+ An address in a country the store does not sell to has no market.
747
+
748
+ ## Images and files
749
+
750
+ An image is a path inside `files/`, optionally with metadata:
751
+
752
+ ```json
753
+ "products/tee-white.jpg"
754
+ { "src": "products/tee-white.jpg", "alt": "White t-shirt", "width": 1600, "height": 2000 }
755
+ ```
756
+
757
+ URLs keep the shape Shopify gives them but point to the local server, never to Shopify's CDN:
758
+
759
+ ```text
760
+ {{ product.featured_image | image_url: width: 400, height: 400, crop: 'center' }}
761
+ → //localhost:9292/cdn/shop/files/products/tee-white.jpg?crop=center&height=400&v=1651229318&width=400
762
+ ```
763
+
764
+ The server resizes and crops on the fly with Shopify's parameters: `width`, `height`, `crop`
765
+ (`top`, `center`, `bottom`, `left`, `right`, or a region with `crop_left`, `crop_top`,
766
+ `crop_width`, `crop_height`), `pad_color`, `format` and `quality`. Images are never scaled up
767
+ beyond their own size, as on Shopify. The legacy `img_url` sizes
768
+ (`shirt_400x400_crop_center.jpg`) work too.
769
+
770
+ **The file does not have to exist.** When it is missing, the server draws a placeholder of the
771
+ declared `width` × `height` (1200 × 1200 when not declared) showing the file name, and answers
772
+ with the header `x-lsf-placeholder: 1`. Layout, aspect ratios and `srcset` are then the same as
773
+ with real images, so fixtures do not need to ship binaries. When the file exists, its real
774
+ size is read from it.
775
+
776
+ `files` gives metadata to files that are referenced by name from theme settings or metafields:
777
+
778
+ ```json
779
+ { "files": { "hero.jpg": { "alt": "Storefront", "width": 2400, "height": 1200 } } }
780
+ ```
781
+
782
+ A setting of type `image_picker` holding `shopify://shop_images/hero.jpg` resolves to
783
+ `files/hero.jpg`.
784
+
785
+ Fonts picked with `font_picker` come from Shopify's font library, which is not available
786
+ offline. Put a font at `files/fonts/<file name>` to serve the real file; otherwise a blank font
787
+ is served so that browsers fall back to the theme's fallback stack without network errors.
788
+
789
+ ## Metafields and metaobjects
790
+
791
+ 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/`.
794
+
795
+ ```json
796
+ {
797
+ "metafields": {
798
+ "custom": {
799
+ "care": "Machine wash cold",
800
+ "rating": 5,
801
+ "organic": true,
802
+ "size_guide": { "type": "page_reference", "value": "size-guide" },
803
+ "related": { "type": "list.product_reference", "value": ["linen-overshirt"] },
804
+ "swatch": { "type": "color", "value": "#f4f1ea" },
805
+ "lookbook": { "type": "file_reference", "value": "lookbook.jpg" }
806
+ }
807
+ }
808
+ }
809
+ ```
810
+
811
+ `{{ product.metafields.custom.care }}` and
812
+ `{{ product.metafields.custom.size_guide.value.title }}` then behave as on Shopify, including
813
+ `metafield_tag` and `metafield_text`.
814
+
815
+ A typed value is checked against its type, as Shopify checks it. `{"type": "number_integer",
816
+ "value": "two"}` is an `invalid_metafield` error, and so is a `weight` in `"kgs"` or a
817
+ `rating` outside its scale. A `weight`, `dimension` or `volume` takes the units Shopify lists
818
+ (`kilograms`, `centimeters`, `milliliters`...) and their short forms (`kg`, `cm`, `ml`). A
819
+ `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.
825
+
826
+ A `file_reference` is an image when its extension is one (`.jpg`, `.png`, `.webp`...) and a
827
+ video for `.mp4`, `.mov`, `.webm` and `.m4v`: `sources`, `preview_image`, `video_tag` and
828
+ `metafield_tag` work.
829
+ A video's poster is the `.jpg` of the same name (`looks/summer.jpg` for `looks/summer.mp4`),
830
+ and its size the one declared in `files` (1920×1080 otherwise). Any other file has a `url`.
831
+
832
+ Metaobjects are listed by type:
833
+
834
+ ```json
835
+ {
836
+ "metaobjects": {
837
+ "designer": [{ "handle": "sam", "fields": { "name": "Sam", "portrait": { "type": "file_reference", "value": "sam.jpg" } } }]
838
+ }
839
+ }
840
+ ```
841
+
842
+ and read with `{{ metaobjects.designer.sam.name }}` or
843
+ `{% for d in metaobjects.designer.values %}`.
844
+
845
+ A type whose theme has `templates/metaobject/<type>.json` (or `.liquid`) is a type with web
846
+ pages on Shopify: each entry is served at `/pages/<type>/<handle>`, the template sees it as
847
+ `metaobject`, and `metaobject.system.url` is that URL. Without the template the entry has no
848
+ page and `system.url` is blank. `"template_suffix": "alt"` on an entry renders it with
849
+ `templates/metaobject/<type>.alt.json`, and `?view=alt` does the same for one request. The page
850
+ title is the entry's `title` or `name` field, or its handle. `shopify://metaobjects/<type>/<handle>`
851
+ in a `url` setting leads to the page.
852
+
853
+ The Functions of the [local checkout](checkout.md) read the same metaobjects as
854
+ `shop.metaobject(handle: {type: "designer", handle: "sam"})`, with their `field(key:)`.
855
+
856
+ The types an app owns are written `$app:<type>` (`"$app:author"`), as its reserved metafield
857
+ namespaces are (`$app:config`). Shopify writes them in full, `app--<app id>--author`, which a
858
+ theme's Liquid uses: `metaobjects['app--1234--author']` and `metaobjects['$app:author']` both
859
+ find them, whatever the id, since the store data does not know it.
860
+
861
+
862
+ ## Time and theme settings
863
+
864
+ - `now` freezes the clock: `"now": "2025-01-15T10:00:00Z"`. `{{ 'now' | date: ... }}`, sale
865
+ countdowns and "new" badges then render the same on every run. As on Shopify, where each
866
+ `'now'` is a new reading of the clock, each `'now'` of a render is one nanosecond after the
867
+ previous one: `{{ 'now' | date: '%N' }}` gives `000000000`, then `000000001`, and so on, so
868
+ ids built from it differ. Without `now`, `'now'` reads the real clock each time.
869
+ - `theme_settings` overrides values of `config/settings_data.json` by setting id, without
870
+ touching the theme: `"theme_settings": { "cart_type": "drawer" }`. Each id and value is
871
+ checked against the theme's `config/settings_schema.json`: an id the theme does not have is
872
+ an `unknown_setting`, and a value its setting does not take (an option a `select` does not
873
+ have, a `range` out of bounds, a `checkbox` that is not `true` or `false`) an
874
+ `invalid_setting`. Without a theme, `validate` cannot check them, and a warning says so.
875
+
876
+ ## The app
877
+
878
+ With `serve --app`, `app` is the app as the store has it installed, for its theme app
879
+ extensions:
880
+
881
+ ```json
882
+ {
883
+ "app": {
884
+ "metafields": { "reviews": { "badge_enabled": true } },
885
+ "embeds": { "chat-bubble": true, "reviews-loader": { "settings": { "api_key": "test-key" } } }
886
+ }
887
+ }
888
+ ```
889
+
890
+ - `metafields` are its app-data metafields, by namespace then key, written as other
891
+ metafields. The Liquid of its blocks reads them as `app.metafields.reviews.badge_enabled`,
892
+ and the `available_if` of a block tests them.
893
+ - `embeds` turns its app embed blocks on or off, by the name of their file in `blocks/`: `true`,
894
+ `false`, or `{"settings": {...}}` to turn one on with these settings. What it does not name
895
+ stays as `config/settings_data.json` has it.
896
+
897
+ ## Error codes
898
+
899
+ | Code | Meaning |
900
+ |---|---|
901
+ | `invalid_json` | The file is not valid JSON. The hint gives the line and column. |
902
+ | `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. |
904
+ | `classic_customer_accounts` | A warning: `shop.customer_accounts` or a customer's `password`, which classic customer accounts had. The field is ignored. |
905
+ | `missing_field` | A required field is absent. |
906
+ | `wrong_type` | A value of the wrong JSON type. |
907
+ | `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. |
908
+ | `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
+ | `duplicate_handle`, `duplicate_id`, `duplicate_email`, `duplicate_menu`, `duplicate_variant`, `duplicate_gift_card` | Two entities with the same identity. |
910
+ | `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. |
913
+ | `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
+ | `duplicate_market_country` | A country in two `localization.markets`. |
915
+ | `unknown_return_line` | A return of an order names a variant that is not a line of the order. |
916
+ | `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
+ | `invalid_throttle` | The `throttle` of a session sent to `PUT /__lsf/session` is not one `--throttle` takes. |
918
+ | `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. |
920
+ | `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
+ | `unchecked_theme_settings` (warning) | The data has `theme_settings` and there is no theme to check them against. |
922
+ | `invalid_color`, `empty_swatch` | A swatch whose color is not a CSS color, or that has neither a color nor an image. |
923
+ | `missing_price` | A product without a price on itself or on its variants. |
924
+ | `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
+ | `media_conflict`, `missing_sources`, `missing_external_video` | Incomplete or contradictory product media. |
926
+ | `ambiguous_link` | A menu link that points at more than one thing. |
927
+ | `missing_locale_file` (warning) | A language the theme has no `locales/<code>.json` for. |
928
+ | `gift_card_balance` (warning) | A gift card whose balance is above its initial value. |
929
+ | `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. |
930
+
931
+ `path` is always a JSON pointer into `file`, so the value to fix can be located mechanically.
932
+
933
+ An entity with a value that cannot be read (a `wrong_type`, an `invalid_value`, an
934
+ `invalid_format`, a `missing_field`) is left out. The rest of its file is still read, with its
935
+ references, so one run reports every problem. Each reference to the entity left out is an error
936
+ too (`unknown_product`, `unknown_variant`, `unknown_customer`, `unknown_reference`), and its
937
+ hint says so: `the product "a" is left out because products/a.json has errors`. Fix that file,
938
+ and these errors go away with it.
939
+
940
+ ## Field reference
941
+
942
+ Every field of every type is listed in [data-reference.md](data-reference.md), which is
943
+ generated from the JSON Schema the validator uses.