@commercelayer/agent-cli 0.1.11 → 0.1.13

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/README.md CHANGED
@@ -17,8 +17,9 @@ npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11
17
17
  | `plan` | Validate `store.yml` and the product files, and list what `apply` would create, update or delete: markets, products, and the store's name and policies. |
18
18
  | `apply [--allow-delete] [--full]` | Make the store match the files. Refuses deletes without `--allow-delete`. Product changes go through a Commerce Layer import: `apply` waits for it, with progress as JSON lines on stderr, and prints one result. `apply_failed` lists the products Commerce Layer refused; While Commerce Layer is busy or unavailable, `apply` keeps reading (backing off on busy) and writes a `retrying` line. `apply_pending` means the import keeps going after 10 minutes, and `c11r status` follows it. An apply still running is finished before a new one starts. `--full` re-imports every product and price, after edits in the Commerce Layer dashboard. |
19
19
  | `catalog pull` | Read the products from the catalog source in `store.yml` (a Supabase table, a Google Sheet or a Google Merchant Center feed) into `.commercelayer/products/catalog.csv`. Then `plan` and `apply` as usual. |
20
- | `storefront init [--force]` | Write `storefront/index.html`: a static page on [drop-in.js](https://commercelayer.github.io/drop-in.js/) listing the products in `store.yml`, grouped by top-level category with a navigation, with a minicart that slides in on add to cart, styled through theme tokens at the top of its `<style>` (`--paper`, `--ink`, `--muted`, `--line`, `--accent`, `--on-accent`, `--radius`, `--font`) that also restyle drop-in.js's prices, buttons and links, so a dark theme is a token change. Product images go in `storefront/img/` (`image_url: img/<file>`); products with no image get a neutral tile. and the hosted cart and checkout, wired to the sandbox's sales channel. Refuses to overwrite without the flag. |
20
+ | `storefront init [--force]` | Write a static storefront on [drop-in.js](https://commercelayer.github.io/drop-in.js/), no build step, wired to the sandbox's sales channel. `index.html`: the config block, the products grouped by top-level category with a navigation, a minicart that slides in on add to cart, the hosted cart and checkout, and a product page at `#/p/<sku>` built from `<template id="product-template">` and the `#products` data. `styles.css`: theme tokens first (`--paper`, `--ink`, `--muted`, `--line`, `--accent`, `--on-accent`, `--radius`, `--font`), which also restyle drop-in.js's prices, buttons and links, so a dark theme is a token change; then the drop-in.js overrides, then the page. `app.js`: the market switch, the category links and the product page. `img/`: product images (`image_url: img/<file>`); products with no image get a neutral tile. Refuses to overwrite without the flag; with it, rewrites the three files. |
21
21
  | `storefront publish` | Upload the folder at `storefront.path` (default `storefront`) to the store host, leaving out dotfiles. Lists products whose `image_url` points at a file that is not in the folder (`missing_images`). |
22
+ | `storefront check` | Read the published storefront at the preview URL and check it: drop-in.js and its config block wired to this store, the drop-in elements, an add to cart for every product and none for unknown skus, every image loading, alt text, stylesheets after drop-in.css, the theme tokens and drop-in overrides (no dead `::part` rules), title, `lang`, viewport, description, and whether the folder changed since publish. Prints `{ status, summary, checks: [{ id, kind, status: pass\|warn\|fail, message, fix, items }], render }` and exits 1 when a check fails; warnings do not fail. `kind` is `static` today; a later rendering phase adds `render` checks and fills `render`. |
22
23
  | `orders test [--sku <id>] [--quantity <n>] [--country <CC>] [--code <discount>] [--via-cart] [--decline]` | Buy from the store over UCP as a shopping agent would: discover, search, (cart,) checkout, address, pay with the sandbox handler, read the order back. Prints the order and every step. |
23
24
  | `orders list` | The orders placed in the store, newest first, as UCP order entities. |
24
25
  | `orders show <id or number>` | One order. |
@@ -84,7 +85,7 @@ catalog:
84
85
  - `merchant`: a Google Merchant Center product feed URL, RSS 2.0 (XML, `g:` namespace) or TSV. `id` is the sku, `title` the name, `description`, `image_link` the image, `price` (`14.50 EUR`) the price in its currency, `product_type` the category. An id listed once per currency is one product with several prices. Other attributes, `sale_price` included, are ignored.
85
86
  Error codes: `no_catalog_source`, `invalid_catalog`, `missing_credentials`, `catalog_unreachable`, `catalog_unauthorized`, `catalog_not_found`, `catalog_not_csv`, `catalog_error`, `empty_catalog`, plus `invalid_product` and `missing_price`.
86
87
 
87
- Error codes: `no_store`, `invalid_yaml`, `invalid_store`, `invalid_market`, `invalid_product`, `missing_price`, `deletes_need_approval`, `apply_failed`, `apply_pending`, `apply_in_progress`, `storefront_exists`, `no_storefront`, `not_applied`, `not_found`, `missing_argument`, `invalid_argument`, `unknown_command`, `unauthorized`, `expired`, `api_unreachable`, `api_error`, `no_product`, `checkout_not_ready`, `ucp_error`, `ucp_profile_unreachable`, `ucp_profile_malformed`, `ucp_version_unsupported`, `ucp_no_rest_service`, `internal`.
88
+ Error codes: `no_store`, `invalid_yaml`, `invalid_store`, `invalid_market`, `invalid_product`, `missing_price`, `deletes_need_approval`, `apply_failed`, `apply_pending`, `apply_in_progress`, `storefront_exists`, `no_storefront`, `storefront_unreachable`, `not_applied`, `not_found`, `missing_argument`, `invalid_argument`, `unknown_command`, `unauthorized`, `expired`, `api_unreachable`, `api_error`, `no_product`, `checkout_not_ready`, `ucp_error`, `ucp_profile_unreachable`, `ucp_profile_malformed`, `ucp_version_unsupported`, `ucp_no_rest_service`, `internal`.
88
89
  ## The store speaks UCP
89
90
  Each store is a UCP business at `https://<org>.sandbox.commercelayer.store` (live stores: `<slug>.commercelayer.store`), implementing release **2026-08-25** over REST:
90
91
  | What | Where |
@@ -86,6 +86,8 @@ function returnPolicy(ctx) {
86
86
  ];
87
87
  }
88
88
  // ---------- catalog
89
+ /** A path in the storefront (img/eth.jpg) is served from the store host: shopping agents always get a full URL. */
90
+ const imageUrl = (ctx, url) => new URL(url, `${ctx.origin}/`).href;
89
91
  function variant(ctx, p, market) {
90
92
  const price = { amount: toMinor(p.prices[market.currency], market.currency), currency: market.currency };
91
93
  const v = {
@@ -98,9 +100,8 @@ function variant(ctx, p, market) {
98
100
  // Inventory lives in Commerce Layer; the sandbox catalog has no stock, so everything is in stock.
99
101
  availability: { available: true, status: 'in_stock' },
100
102
  };
101
- // A path in the storefront (img/eth.jpg) is served from the store host: shopping agents get the full URL.
102
103
  if (p.image_url)
103
- v.media = [{ type: 'image', url: new URL(p.image_url, `${ctx.origin}/`).href }];
104
+ v.media = [{ type: 'image', url: imageUrl(ctx, p.image_url) }];
104
105
  if (ctx.shared.has('dev.ucp.shopping.fulfillment')) {
105
106
  const o = shippingOption(market);
106
107
  v.fulfillment = { methods: [{ type: 'shipping', description: { plain: `Ships to ${market.countries.join(', ')}` }, availability: v.availability, options: [{ id: o.id, title: o.title, description: o.description }] }] };
@@ -202,7 +203,7 @@ function buildLines(ctx, market, requested, existing = []) {
202
203
  const prev = existing.find((e) => e.id === l.id) ?? existing.find((e) => e.item.id === p.sku);
203
204
  const item = { id: p.sku, title: p.name, price };
204
205
  if (p.image_url)
205
- item.image_url = p.image_url;
206
+ item.image_url = imageUrl(ctx, p.image_url);
206
207
  lines.push({ id: prev?.id ?? `li_${++n}`, item, quantity: qty, totals: [{ type: 'subtotal', amount: price * qty }, { type: 'total', amount: price * qty }] });
207
208
  });
208
209
  return { lines, messages };
package/dist/bin.js CHANGED
@@ -50,7 +50,9 @@ async function run() {
50
50
  return cmd.storefrontInit(flags);
51
51
  if (sub === 'publish')
52
52
  return cmd.storefrontPublish();
53
- throw new CliError('unknown_command', `There is no command "storefront ${sub ?? ''}".`, 'Use `c11r storefront init` or `c11r storefront publish`.');
53
+ if (sub === 'check')
54
+ return cmd.storefrontCheckCmd();
55
+ throw new CliError('unknown_command', `There is no command "storefront ${sub ?? ''}".`, 'Use `c11r storefront init`, `storefront publish` or `storefront check`.');
54
56
  case 'orders':
55
57
  if (sub === 'test')
56
58
  return cmd.ordersTest(flags, values);
package/dist/check.js ADDED
@@ -0,0 +1,187 @@
1
+ // c11r storefront check: read the published storefront at preview_url, as a browser
2
+ // would fetch it, and report what would break it or what is still the scaffold's.
3
+ // This is the static phase: HTML and CSS as served, images by request. Every check
4
+ // has a kind; a rendering phase (contrast, overflow, minicart fit, screenshots)
5
+ // adds checks of kind "render" and fills `render`, without changing the shape.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { send } from './api/transport.js';
9
+ import { fail } from './output.js';
10
+ import { storefrontPath } from './store.js';
11
+ const decode = (t) => t.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos);/gi, (m, e) => {
12
+ const named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
13
+ if (e[0] !== '#')
14
+ return named[e.toLowerCase()] ?? m;
15
+ return String.fromCodePoint(e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : Number(e.slice(1)));
16
+ });
17
+ /** Every start tag with its attributes, comments and script bodies left out. */
18
+ function tags(html) {
19
+ const text = html.replace(/<!--[\s\S]*?-->/g, '').replace(/(<script\b[^>]*>)[\s\S]*?(<\/script>)/gi, '$1$2');
20
+ return [...text.matchAll(/<([a-zA-Z][\w-]*)((?:\s+[^\s=/>]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*\/?>/g)].map(([, name, raw]) => {
21
+ const attrs = {};
22
+ for (const [, k, d, s, u] of raw.matchAll(/([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g))
23
+ attrs[k.toLowerCase()] = decode(d ?? s ?? u ?? '');
24
+ return { name: name.toLowerCase(), attrs };
25
+ });
26
+ }
27
+ const scripts = (html) => [...html.replace(/<!--[\s\S]*?-->/g, '').matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)].map(([, attrs, body]) => ({ attrs, body }));
28
+ /** GET a URL: through the CLI's transport on the store host (so the stub works), plain fetch elsewhere. */
29
+ async function get(url, origin, method = 'GET') {
30
+ if (new URL(url).origin === origin) {
31
+ const r = await send('GET', url);
32
+ return { status: r.status, type: r.headers['content-type'] ?? '', body: r.body };
33
+ }
34
+ try {
35
+ let r = await fetch(url, { method, redirect: 'follow' });
36
+ if (method === 'HEAD' && (r.status === 405 || r.status === 403))
37
+ r = await fetch(url, { redirect: 'follow' });
38
+ return { status: r.status, type: r.headers.get('content-type') ?? '', body: method === 'HEAD' ? '' : await r.text() };
39
+ }
40
+ catch (e) {
41
+ return { status: 0, type: '', body: String(e.cause?.message ?? e.message) };
42
+ }
43
+ }
44
+ /** In batches, so a large catalog does not open hundreds of connections at once. */
45
+ async function mapLimit(items, limit, fn) {
46
+ const out = [];
47
+ for (let i = 0; i < items.length; i += limit)
48
+ out.push(...(await Promise.all(items.slice(i, i + limit).map(fn))));
49
+ return out;
50
+ }
51
+ const check = (id, status, message, extra = {}) => ({ id, kind: 'static', status, message, ...extra });
52
+ export async function storefrontCheck(api) {
53
+ const state = await api.getStore();
54
+ if (!state.storefront)
55
+ fail('no_storefront', 'Nothing is published at the store host yet.', 'Run `c11r storefront init` if there is no storefront folder, then `c11r storefront publish`.');
56
+ const origin = new URL(state.preview_url).origin;
57
+ const pageUrl = `${origin}/`;
58
+ const page = await get(pageUrl, origin);
59
+ if (page.status !== 200 || !page.type.includes('text/html'))
60
+ fail('storefront_unreachable', `${pageUrl} answered ${page.status} ${page.type}.`, 'Run `c11r storefront publish` again; if it keeps happening, tell the human, with this output.');
61
+ const html = page.body;
62
+ const all = tags(html);
63
+ const find = (name) => all.filter((t) => t.name === name);
64
+ const checks = [];
65
+ // drop-in.js: the script, its stylesheets, and the config block wired to this store's sales channel.
66
+ const dropInJs = find('script').some((t) => /@commercelayer\/drop-in\.js@[^/]+\/dist\/drop-in\/drop-in\.esm\.js/.test(t.attrs.src ?? '') && t.attrs.type === 'module');
67
+ checks.push(dropInJs ? check('drop_in_script', 'pass', 'drop-in.js is loaded as a module.') : check('drop_in_script', 'fail', 'drop-in.js is not loaded: prices, cart and checkout will not work.', { fix: 'Keep <script type="module" src="https://cdn.jsdelivr.net/npm/@commercelayer/drop-in.js@2/dist/drop-in/drop-in.esm.js"> in the <head>, as storefront init writes it.' }));
68
+ const config = scripts(html).find((s) => s.body.includes('commercelayerConfig'))?.body ?? '';
69
+ const clientId = config.match(/clientId\s*:\s*['"]([^'"]+)['"]/)?.[1];
70
+ if (!config)
71
+ checks.push(check('config', 'fail', 'There is no window.commercelayerConfig block.', { fix: 'Restore the config <script> from storefront init (clientId, scope, defaultAttributes) before the drop-in.js script.' }));
72
+ else if (clientId !== state.sales_channel_client_id)
73
+ checks.push(check('config', 'fail', `The config block's clientId is ${clientId ? `"${clientId}"` : 'missing'}, not this store's sales channel.`, { fix: `Set clientId: '${state.sales_channel_client_id}' in the config block.` }));
74
+ else if (!/market:code:/.test(config))
75
+ checks.push(check('config', 'fail', 'The config block has no market scope.', { fix: "Keep scope: 'market:code:' + market in the config block." }));
76
+ else
77
+ checks.push(check('config', 'pass', "The config block uses this store's sales channel and a market scope."));
78
+ // The elements a shopper needs, and an add to cart for every product applied.
79
+ const missing = ['cl-price', 'cl-add-to-cart', 'cl-cart-link', 'cl-checkout-link'].filter((n) => find(n).length === 0);
80
+ checks.push(missing.length ? check('drop_in_elements', 'fail', `The page has no ${missing.join(', ')}.`, { fix: 'Keep the drop-in.js elements storefront init writes: cl-price and cl-add-to-cart per product, cl-cart-link with cl-cart-count and the mini cl-cart, cl-checkout-link.', items: missing }) : check('drop_in_elements', 'pass', 'Prices, add to cart, the cart link and the checkout link are on the page.'));
81
+ let data = {};
82
+ const json = scripts(html).find((s) => /id\s*=\s*["']?products["']?/.test(s.attrs) && /application\/json/.test(s.attrs));
83
+ if (json) {
84
+ try {
85
+ data = JSON.parse(json.body);
86
+ }
87
+ catch {
88
+ checks.push(check('product_data', 'fail', 'The #products data is not valid JSON: product pages will not open.', { fix: 'Fix the JSON in <script type="application/json" id="products">, or regenerate with `c11r storefront init --force`.' }));
89
+ }
90
+ }
91
+ const templated = json && all.some((t) => t.name === 'cl-add-to-cart' && 'data-code' in t.attrs);
92
+ const codes = new Set(all.filter((t) => t.name.startsWith('cl-') && t.attrs.code).map((t) => t.attrs.code));
93
+ const buyable = new Set([...find('cl-add-to-cart').map((t) => t.attrs.code).filter(Boolean), ...(templated ? Object.keys(data) : [])]);
94
+ const skus = Object.keys(state.applied?.products ?? {});
95
+ const unsold = skus.filter((s) => !buyable.has(s));
96
+ const unknown = [...new Set([...codes, ...(templated ? Object.keys(data) : [])])].filter((c) => !skus.includes(c));
97
+ if (unknown.length)
98
+ checks.push(check('products', 'fail', `The page offers skus the store does not have: ${unknown.join(', ')}.`, { fix: 'Use the skus in the catalog (code="<sku>"), or run `c11r apply` if the catalog has them and the store does not yet.', items: unknown }));
99
+ else if (unsold.length)
100
+ checks.push(check('products', 'warn', `${unsold.length} of ${skus.length} products have no add to cart on the page.`, { fix: 'Add a card with <cl-add-to-cart code="<sku>"> for each, or regenerate with `c11r storefront init --force`.', items: unsold }));
101
+ else
102
+ checks.push(check('products', 'pass', `All ${skus.length} products can be added to the cart.`));
103
+ // Images: every <img> and product image, requested; and alt text on the page's images.
104
+ const imageUrls = new Set();
105
+ for (const t of find('img'))
106
+ if (t.attrs.src && !('data-field' in t.attrs))
107
+ imageUrls.add(t.attrs.src);
108
+ for (const p of Object.values(data))
109
+ if (p?.image_url)
110
+ imageUrls.add(p.image_url);
111
+ const resolved = [...imageUrls].slice(0, 200).map((src) => {
112
+ try {
113
+ return { src, url: new URL(src, pageUrl).href };
114
+ }
115
+ catch {
116
+ return { src, url: '' };
117
+ }
118
+ });
119
+ const answers = await mapLimit(resolved, 8, async (i) => ({ ...i, res: i.url ? await get(i.url, origin, 'HEAD') : { status: 0, type: '', body: 'not a URL' } }));
120
+ const broken = answers.filter((a) => a.res.status < 200 || a.res.status >= 300 || (a.res.type && !a.res.type.startsWith('image/')));
121
+ checks.push(broken.length
122
+ ? check('images', 'fail', `${broken.length} of ${answers.length} images do not load.`, { fix: 'Put the files in the storefront folder (img/<file>) and publish, or fix the URLs.', items: broken.map((b) => `${b.src}: ${b.res.status || b.res.body}${b.res.type && !b.res.type.startsWith('image/') ? ` ${b.res.type}` : ''}`) })
123
+ : check('images', 'pass', answers.length ? `All ${answers.length} images load.` : 'The page has no images.'));
124
+ const noAlt = find('img').filter((t) => !('alt' in t.attrs) && !('data-field' in t.attrs)).map((t) => t.attrs.src ?? '(no src)');
125
+ checks.push(noAlt.length ? check('image_alt', 'warn', `${noAlt.length} images have no alt text.`, { fix: 'Give every <img> an alt: the product name, or alt="" for decoration.', items: noAlt }) : check('image_alt', 'pass', 'Every image has alt text.'));
126
+ // Styles: the page's own CSS after drop-in.css, the theme tokens, overrides that can apply.
127
+ const sheets = find('link').filter((t) => /\bstylesheet\b/i.test(t.attrs.rel ?? '') && t.attrs.href);
128
+ const dropInCss = sheets.findIndex((t) => /drop-in\.js@[^/]+\/dist\/drop-in\/drop-in\.css/.test(t.attrs.href));
129
+ const own = sheets.filter((t) => new URL(t.attrs.href, pageUrl).origin === origin);
130
+ const inline = [...html.replace(/<!--[\s\S]*?-->/g, '').matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1]);
131
+ const ownCss = await mapLimit(own, 4, async (t) => (await get(new URL(t.attrs.href, pageUrl).href, origin)).body);
132
+ const css = [...ownCss, ...inline].join('\n');
133
+ if (dropInCss < 0)
134
+ checks.push(check('stylesheets', 'warn', 'drop-in.css is not linked: drop-in.js elements fall back to unstyled.', { fix: 'Keep <link href="https://cdn.jsdelivr.net/npm/@commercelayer/drop-in.js@2/dist/drop-in/drop-in.css" rel="stylesheet">, before the page\'s own stylesheet.' }));
135
+ else if (own.some((t) => sheets.indexOf(t) < dropInCss))
136
+ checks.push(check('stylesheets', 'warn', "A stylesheet of the page loads before drop-in.css, so drop-in.css's rules win over it.", { fix: 'Link the page\'s stylesheets after drop-in.css and minicart.css.', items: own.filter((t) => sheets.indexOf(t) < dropInCss).map((t) => t.attrs.href) }));
137
+ else
138
+ checks.push(check('stylesheets', 'pass', "The page's stylesheets load after drop-in.css."));
139
+ const tokens = ['--paper', '--ink', '--accent'].filter((t) => !new RegExp(`${t}\\s*:`).test(css));
140
+ checks.push(tokens.length ? check('theme_tokens', 'warn', `The theme tokens ${tokens.join(', ')} are not defined.`, { fix: 'Keep the tokens block at the top of styles.css and restyle through it.', items: tokens }) : check('theme_tokens', 'pass', 'The theme tokens are defined.'));
141
+ const dead = [...css.matchAll(/(cl-add-to-cart|cl-checkout-link|cl-cart-link)::part\([^)]*\)/g)].map((m) => m[0]);
142
+ const overrides = /cl-price-amount\s*\{[^}]*color\s*:/.test(css) && /cl-add-to-cart(?::not\(\[aria-disabled=["']?true["']?\]\))?\s*\{[^}]*background(-color)?\s*:/.test(css);
143
+ if (dead.length)
144
+ checks.push(check('drop_in_overrides', 'fail', 'Some rules style ::part()s that these drop-in.js elements do not have: they never apply.', { fix: 'Style the elements themselves (cl-add-to-cart, cl-checkout-link a), as styles.css does.', items: [...new Set(dead)] }));
145
+ else if (!overrides)
146
+ checks.push(check('drop_in_overrides', 'warn', "Nothing overrides drop-in.css's colours for prices and the add to cart button, so they ignore the theme.", { fix: 'Keep the "drop-in.js overrides" section of styles.css.' }));
147
+ else
148
+ checks.push(check('drop_in_overrides', 'pass', 'Prices and the add to cart button follow the theme.'));
149
+ // The page itself.
150
+ const title = decode(html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? '').trim();
151
+ if (!title)
152
+ checks.push(check('title', 'fail', 'The page has no <title>.', { fix: 'Add a <title> with the store name and what it sells.' }));
153
+ else if (title === state.name)
154
+ checks.push(check('title', 'warn', `The <title> is still the scaffold's: "${title}".`, { fix: 'Say what the store sells too, e.g. "<name> · Specialty coffee".' }));
155
+ else
156
+ checks.push(check('title', 'pass', `The <title> is "${title}".`));
157
+ const lang = find('html')[0]?.attrs.lang;
158
+ checks.push(lang ? check('lang', 'pass', `The page language is ${lang}.`) : check('lang', 'warn', 'The <html> element has no lang.', { fix: 'Add lang="en" (or the store\'s language) to <html>.' }));
159
+ const viewport = find('meta').some((t) => t.attrs.name === 'viewport');
160
+ checks.push(viewport ? check('viewport', 'pass', 'The page has a viewport meta tag.') : check('viewport', 'fail', 'The page has no viewport meta tag: phones show it zoomed out.', { fix: 'Add <meta name="viewport" content="width=device-width, initial-scale=1">.' }));
161
+ const description = find('meta').find((t) => t.attrs.name === 'description')?.attrs.content;
162
+ checks.push(description ? check('description', 'pass', 'The page has a meta description.') : check('description', 'warn', 'The page has no meta description, what search results and link previews show.', { fix: 'Add <meta name="description" content="<one sentence on the store>">.' }));
163
+ // The local folder against what is published.
164
+ const dir = storefrontPath();
165
+ if (fs.existsSync(dir)) {
166
+ const since = Date.parse(state.storefront.published_at);
167
+ const newer = fs
168
+ .readdirSync(dir, { recursive: true, withFileTypes: true })
169
+ .filter((e) => e.isFile() && !path.relative(dir, path.join(e.parentPath, e.name)).split(path.sep).some((p) => p.startsWith('.')))
170
+ .filter((e) => fs.statSync(path.join(e.parentPath, e.name)).mtimeMs > since)
171
+ .map((e) => path.relative(dir, path.join(e.parentPath, e.name)).split(path.sep).join('/'));
172
+ checks.push(newer.length ? check('published', 'warn', `${newer.length} files in ${dir}/ changed after the last publish: this check read the published version.`, { fix: 'Run `c11r storefront publish`, then check again.', items: newer }) : check('published', 'pass', `The published storefront matches ${dir}/.`));
173
+ }
174
+ const summary = { pass: 0, warn: 0, fail: 0 };
175
+ for (const c of checks)
176
+ summary[c.status]++;
177
+ return {
178
+ status: summary.fail ? 'fail' : 'pass',
179
+ url: pageUrl,
180
+ published_at: state.storefront.published_at,
181
+ summary,
182
+ checks,
183
+ // Filled by the rendering phase (1440 and 390 px: contrast, overflow, minicart fit, screenshots); null until then.
184
+ render: null,
185
+ next: summary.fail ? 'Fix the failed checks (see fix), publish, and check again.' : summary.warn ? 'The storefront works. The warnings are worth fixing before the human sees it.' : undefined,
186
+ };
187
+ }
package/dist/commands.js CHANGED
@@ -7,8 +7,9 @@ import { createApi } from './api/client.js';
7
7
  import { ignoreCredentials, paths, readCredentials, requireCredentials, writeCredentials } from './files.js';
8
8
  import { CliError, fail, progress } from './output.js';
9
9
  import { diff, hashStore, isLocalImage, loadStore, storefrontPath, template, toApplied } from './store.js';
10
- import { storefrontHtml } from './storefront.js';
10
+ import { storefrontFiles } from './storefront.js';
11
11
  import { pull } from './catalog.js';
12
+ import { storefrontCheck } from './check.js';
12
13
  import { buy } from './ucp/buy.js';
13
14
  import { check } from './ucp/check.js';
14
15
  import { discover } from './ucp/client.js';
@@ -20,7 +21,8 @@ export const HELP = {
20
21
  plan: 'Show what apply would create, update or delete: markets, products and the store\'s name and policies, from .commercelayer/store.yml and the product files under .commercelayer/products/ (*.yml lists, *.csv rows, merged by sku).',
21
22
  apply: 'Make the store match .commercelayer/store.yml and .commercelayer/products/. Refuses deletes unless --allow-delete is given. Product changes go through a Commerce Layer import: apply waits for it (progress as JSON lines on stderr) and prints one result; if it stops with apply_pending the import keeps going, so run `c11r status` to follow it. While Commerce Layer is busy or unavailable it keeps reading, with a retrying line. An apply still running is finished first. --full re-imports every product and price, after edits in the Commerce Layer dashboard.',
22
23
  'catalog pull': 'Read the products from the catalog source in store.yml (catalog: { source: supabase | sheets | merchant, url, table }) and write them to .commercelayer/products/catalog.csv; then plan and apply as usual. Columns: sku, name, description, image_url, category and one price column per market currency, in lowercase (eur, usd). Supabase reads C11R_SUPABASE_URL and C11R_SUPABASE_KEY (the anon key) from .commercelayer/credentials or the environment; a Google Sheet needs its CSV link or a link anyone can open; a Merchant Center feed is RSS/XML or TSV.',
23
- 'storefront init': 'Write storefront/index.html: a static page on drop-in.js listing the products in store.yml, with a minicart that opens on add to cart, and the hosted cart and checkout. Refuses to overwrite unless --force is given.',
24
+ 'storefront init': 'Write a static storefront on drop-in.js, no build step: storefront/index.html (the products by category, a minicart that opens on add to cart, the hosted cart and checkout, and a product page at #/p/<sku>), styles.css (theme tokens first) and app.js, plus img/ for product images. Refuses to overwrite unless --force is given.',
25
+ 'storefront check': 'Check the published storefront at the preview URL: drop-in.js and its config block wired to this store, the drop-in elements, an add to cart for every product, every image loading, alt text, stylesheets after drop-in.css, the theme tokens and drop-in overrides, title, lang, viewport, description, and whether the folder changed since publish. Prints { status, summary, checks: [{ id, kind, status: pass|warn|fail, message, fix, items }] }; exits 1 when a check fails.',
24
26
  'storefront publish': 'Upload the storefront folder (store.yml storefront.path, default storefront) to the sandbox preview URL.',
25
27
  'orders test': 'Buy from the store over UCP as a shopping agent would, paying with the sandbox handler: discover, search, checkout, address, pay, read the order. Options: --sku <id> --quantity <n> --country <CC> --code <discount> --via-cart --decline.',
26
28
  'orders list': 'List the orders placed in the store, newest first, as UCP order entities.',
@@ -164,9 +166,13 @@ export async function storefrontInit(flags) {
164
166
  fail('storefront_exists', `${dir}/ already exists.`, 'Edit the files in it, or run `c11r storefront init --force` to regenerate them from store.yml (this overwrites your changes).');
165
167
  }
166
168
  const state = await api.getStore();
167
- fs.mkdirSync(dir, { recursive: true });
168
- const file = path.join(dir, 'index.html');
169
- fs.writeFileSync(file, storefrontHtml(desired, state.sales_channel_client_id));
169
+ const written = [];
170
+ for (const [name, content] of Object.entries(storefrontFiles(desired, state.sales_channel_client_id))) {
171
+ const file = path.join(dir, name);
172
+ fs.mkdirSync(path.dirname(file), { recursive: true });
173
+ fs.writeFileSync(file, content);
174
+ written.push(file);
175
+ }
170
176
  // img/ is where product images go (image_url: img/<file>); .gitkeep keeps the empty folder in git, and publish skips dotfiles.
171
177
  const keep = path.join(dir, 'img', '.gitkeep');
172
178
  fs.mkdirSync(path.dirname(keep), { recursive: true });
@@ -174,12 +180,15 @@ export async function storefrontInit(flags) {
174
180
  fs.writeFileSync(keep, '');
175
181
  return {
176
182
  status: 'created',
177
- files: [file, keep],
183
+ files: [...written, keep],
178
184
  markets: desired.markets.map((m) => m.code),
179
185
  products: desired.products.length,
180
- next: `Set storefront.path: ${dir} in ${paths.store}, make the page the human's (restyle it through the theme tokens at the top of its <style>; product images go in ${dir}/img/ with image_url: img/<file>), then run c11r storefront publish.`,
186
+ next: `Set storefront.path: ${dir} in ${paths.store}, make it the human's (restyle through the tokens at the top of styles.css; product images go in ${dir}/img/ with image_url: img/<file>; each product has a page at #/p/<sku>, built from #product-template in index.html), then run c11r storefront publish.`,
181
187
  };
182
188
  }
189
+ export async function storefrontCheckCmd() {
190
+ return storefrontCheck(createApi(requireCredentials()));
191
+ }
183
192
  export async function storefrontPublish() {
184
193
  const api = createApi(requireCredentials());
185
194
  const dir = storefrontPath();
@@ -1,16 +1,25 @@
1
- // The storefront scaffold: one static page on drop-in.js listing the products in
2
- // store.yml, grouped by top-level category, with the minicart, the hosted cart and
3
- // checkout. The agent then makes it the human's.
1
+ // The storefront scaffold: a static site on drop-in.js, no build step. Three files:
2
+ // index.html (the config block, the drop-in elements, the products grouped by
3
+ // top-level category), styles.css (theme tokens first, then the drop-in.css
4
+ // overrides, then the page) and app.js (market switch, category links, and a
5
+ // product page at #/p/<sku>). The agent then makes it the human's.
4
6
  const esc = (t) => String(t ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
5
- export function storefrontHtml(store, clientId) {
7
+ /** The scaffold's files, by path relative to the storefront folder. */
8
+ export function storefrontFiles(store, clientId) {
9
+ return { 'index.html': indexHtml(store, clientId), 'styles.css': STYLES, 'app.js': APP };
10
+ }
11
+ function indexHtml(store, clientId) {
6
12
  const name = esc(store.name);
7
- const card = (p, h) => ` <article class="product">
8
- ${p.image_url ? ` <img src="${esc(p.image_url)}" alt="">` : ' <div class="placeholder" aria-hidden="true"></div>'}
9
- <${h}>${esc(p.name)}</${h}>
13
+ const card = (p, h) => {
14
+ const href = `#/p/${encodeURIComponent(p.sku)}`;
15
+ return ` <article class="product" id="p-${esc(p.sku)}">
16
+ <a class="media" href="${esc(href)}" tabindex="-1">${p.image_url ? `<img src="${esc(p.image_url)}" alt="${esc(p.name)}">` : '<div class="placeholder" aria-hidden="true"></div>'}</a>
17
+ <${h}><a href="${esc(href)}">${esc(p.name)}</a></${h}>
10
18
  ${p.description ? ` <p>${esc(p.description)}</p>\n` : ''} <cl-price code="${esc(p.sku)}"><cl-price-amount type="compare-at"></cl-price-amount> <cl-price-amount type="price"></cl-price-amount></cl-price>
11
19
  <cl-availability code="${esc(p.sku)}"><cl-availability-status type="available">In stock</cl-availability-status><cl-availability-status type="unavailable">Sold out</cl-availability-status></cl-availability>
12
20
  <cl-add-to-cart code="${esc(p.sku)}">Add to cart</cl-add-to-cart>
13
21
  </article>`;
22
+ };
14
23
  // Products are grouped by the top level of their category, in order of first appearance;
15
24
  // products with no category come last, under "Other". No categories: one grid.
16
25
  const groups = new Map();
@@ -18,11 +27,10 @@ ${p.description ? ` <p>${esc(p.description)}</p>\n` : ''} <cl-pric
18
27
  const top = p.category?.split(' > ')[0] ?? '';
19
28
  groups.set(top, [...(groups.get(top) ?? []), p]);
20
29
  }
21
- const categorized = [...groups.keys()].some((k) => k);
22
- let body;
30
+ let listing;
23
31
  let categoryNav = '';
24
- if (!categorized)
25
- body = ` <div class="grid">\n${store.products.map((p) => card(p, 'h2')).join('\n')}\n </div>`;
32
+ if (![...groups.keys()].some((k) => k))
33
+ listing = ` <div class="grid">\n${store.products.map((p) => card(p, 'h2')).join('\n')}\n </div>`;
26
34
  else {
27
35
  const ordered = [...groups.keys()].filter((k) => k).concat(groups.has('') ? [''] : []);
28
36
  const used = new Set();
@@ -35,21 +43,25 @@ ${p.description ? ` <p>${esc(p.description)}</p>\n` : ''} <cl-pric
35
43
  return id;
36
44
  });
37
45
  categoryNav = ` <nav class="categories">\n${ordered.map((k, i) => ` <a href="#${ids[i]}">${esc(k || 'Other')}</a>`).join('\n')}\n </nav>\n`;
38
- body = ordered
39
- .map((k, i) => ` <section id="${ids[i]}">\n <h2>${esc(k || 'Other')}</h2>\n <div class="grid">\n${groups.get(k).map((p) => card(p, 'h3')).join('\n')}\n </div>\n </section>`)
46
+ listing = ordered
47
+ .map((k, i) => ` <section id="${ids[i]}">\n <h2>${esc(k || 'Other')}</h2>\n <div class="grid">\n${groups.get(k).map((p) => card(p, 'h3').replace(/^/gm, ' ')).join('\n')}\n </div>\n </section>`)
40
48
  .join('\n');
41
49
  }
42
50
  const options = store.markets.map((m) => ` <option value="${esc(m.code)}">${esc(m.name)} (${esc(m.currency)})</option>`).join('\n');
51
+ // What the product page shows, by sku. Prices and availability come live from drop-in.js.
52
+ const data = Object.fromEntries(store.products.map((p) => [p.sku, { name: p.name, description: p.description, image_url: p.image_url, category: p.category }]));
53
+ const json = JSON.stringify(data, null, 2).replace(/</g, '\\u003c');
43
54
  return `<!doctype html>
44
55
  <html lang="en">
45
56
  <head>
46
57
  <meta charset="utf-8">
47
58
  <meta name="viewport" content="width=device-width, initial-scale=1">
48
59
  <title>${name}</title>
49
- <!-- Commerce Layer drop-in.js: prices, availability, add to cart, the minicart (a slide-in panel, styled by minicart.css) and links to the hosted cart and checkout.
50
- Product images: put them in img/ next to this page and set image_url: img/<file> in the catalog. -->
60
+ <!-- Commerce Layer drop-in.js: prices, availability, add to cart, the minicart (a slide-in panel, styled by minicart.css) and links to the hosted cart and checkout. -->
51
61
  <link href="https://cdn.jsdelivr.net/npm/@commercelayer/drop-in.js@2/dist/drop-in/drop-in.css" rel="stylesheet">
52
62
  <link href="https://cdn.jsdelivr.net/npm/@commercelayer/drop-in.js@2/dist/drop-in/minicart.css" rel="stylesheet">
63
+ <!-- After the drop-in.js stylesheets: styles.css overrides them with the same selectors. Restyle through its tokens. -->
64
+ <link href="styles.css" rel="stylesheet">
53
65
  <script>
54
66
  // The market comes from ?market=<code>; the first market in store.yml is the default. Keep clientId as it is.
55
67
  var market = new URLSearchParams(location.search).get('market') || '${esc(store.markets[0].code)}';
@@ -60,71 +72,14 @@ ${p.description ? ` <p>${esc(p.description)}</p>\n` : ''} <cl-pric
60
72
  };
61
73
  </script>
62
74
  <script type="module" src="https://cdn.jsdelivr.net/npm/@commercelayer/drop-in.js@2/dist/drop-in/drop-in.esm.js"></script>
63
- <style>
64
- /* Theme: restyle the page by changing these tokens; a dark theme is color-scheme: dark with paper and ink swapped.
65
- Keep this <style> after the drop-in.js stylesheets: its rules use the same selectors as drop-in.css, so they win by coming later. */
66
- :root {
67
- color-scheme: light;
68
- --paper: #ffffff; /* page background */
69
- --ink: #16181d; /* text and prices */
70
- --muted: #5d6572; /* secondary text, compare-at prices, availability */
71
- --line: #e3e6ea; /* borders, disabled buttons */
72
- --accent: #1f5eff; /* buttons, link hover */
73
- --on-accent: #ffffff; /* text on the accent */
74
- --radius: 8px;
75
- --font: system-ui, sans-serif;
76
- /* drop-in.css's own defaults (add to cart, cart count) read these. */
77
- --cl-color-primary: var(--accent);
78
- --cl-color-white: var(--on-accent);
79
- }
80
- body { margin: 0; font-family: var(--font); color: var(--ink); background: var(--paper); }
81
- a { color: inherit; }
82
- header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; padding: 16px 24px; border-bottom: 1px solid var(--line); }
83
- header h1 { margin: 0; font-size: 20px; }
84
- /* The nav never wraps inside: on a narrow screen it moves under the store name as a whole. */
85
- header nav { display: flex; align-items: center; gap: 16px; margin-left: auto; white-space: nowrap; }
86
- header select { font: inherit; color: inherit; background: var(--paper); border: 1px solid var(--line); border-radius: calc(var(--radius) / 2); padding: 4px 6px; }
87
- nav.categories { display: flex; flex-wrap: wrap; gap: 8px 20px; padding: 14px 24px; border-bottom: 1px solid var(--line); }
88
- nav.categories a { text-decoration: none; font-size: 15px; }
89
- nav.categories a:hover { color: var(--accent); }
90
- main { max-width: 1100px; margin: 0 auto; padding: 32px 24px; }
91
- main section { scroll-margin-top: 16px; }
92
- main section + section { margin-top: 40px; }
93
- main section > h2 { margin: 0 0 16px; font-size: 22px; }
94
- .grid { display: grid; gap: 24px; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
95
- .product { display: flex; flex-direction: column; gap: 8px; padding: 20px; border: 1px solid var(--line); border-radius: var(--radius); }
96
- .product img, .product .placeholder { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: calc(var(--radius) - 2px); }
97
- /* No image: a plain tile a shade off the page, in any theme. */
98
- .product .placeholder { background: color-mix(in srgb, var(--ink) 6%, var(--paper)); }
99
- .product h2, .product h3 { margin: 0; font-size: 17px; }
100
- .product p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; }
101
- /* drop-in.css colours cl-price-amount itself (#101111), so inheriting is not enough: set it here. */
102
- cl-price-amount { color: var(--ink); font-weight: 600; }
103
- cl-price-amount[type="compare-at"] { color: var(--muted); font-weight: 400; }
104
- cl-availability { font-size: 13px; color: var(--muted); }
105
- /* cl-add-to-cart is the button itself (it has no ::part); drop-in.css colours it with these same selectors. */
106
- cl-add-to-cart { margin-top: auto; align-self: flex-start; padding: 10px 16px; border-radius: calc(var(--radius) - 2px); font: inherit; font-weight: 600; background-color: var(--line); color: var(--muted); }
107
- cl-add-to-cart:not([aria-disabled=true]) { background-color: var(--accent); color: var(--on-accent); }
108
- /* cl-checkout-link renders a plain <a> (no shadow DOM); without an order it is aria-disabled. */
109
- cl-checkout-link a { display: inline-block; padding: 8px 14px; border-radius: calc(var(--radius) - 2px); background: var(--accent); color: var(--on-accent); font-weight: 600; text-decoration: none; }
110
- cl-checkout-link[aria-disabled="true"] a { background: var(--line); color: var(--muted); }
111
- /* drop-in.css styles the count as a badge beside a cart icon (absolute, pushed right). The link is text here, so render it inline: "Cart (3)". */
112
- cl-cart-link cl-cart-count { position: static; transform: none; display: inline; min-width: 0; height: auto; padding: 0; border-radius: 0; background: none; color: inherit; font-size: inherit; }
113
- footer { padding: 24px; border-top: 1px solid var(--line); color: var(--muted); font-size: 13px; }
114
- @media (max-width: 480px) {
115
- header, nav.categories { padding-left: 16px; padding-right: 16px; }
116
- header nav { gap: 12px; }
117
- .market-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
118
- main { padding: 24px 16px; }
119
- }
120
- </style>
75
+ <script src="app.js" defer></script>
121
76
  </head>
122
77
  <body>
123
78
  <header>
124
- <h1>${name}</h1>
79
+ <h1><a href="#">${name}</a></h1>
125
80
  <nav>
126
81
  <label><span class="market-label">Market</span>
127
- <select onchange="location.search = '?market=' + this.value">
82
+ <select id="market">
128
83
  ${options}
129
84
  </select>
130
85
  </label>
@@ -133,11 +88,190 @@ ${options}
133
88
  </nav>
134
89
  </header>
135
90
  ${categoryNav} <main>
136
- ${body}
91
+ <div id="listing">
92
+ ${listing}
93
+ </div>
94
+ <!-- The product page: app.js fills it from #product-template and #products when the address is #/p/<sku>. -->
95
+ <div id="product-page" hidden></div>
137
96
  </main>
138
97
  <footer>Sandbox store: test payments only.</footer>
139
- <script>document.querySelector('header select').value = market;</script>
98
+
99
+ <!-- The product page's markup. Elements with data-field get the product's data; elements with data-code get code="<sku>" for drop-in.js. -->
100
+ <template id="product-template">
101
+ <article class="product-page">
102
+ <a class="back" href="#">&larr; All products</a>
103
+ <div class="media"><img data-field="image_url" alt=""><div class="placeholder" aria-hidden="true"></div></div>
104
+ <div class="info">
105
+ <p class="category" data-field="category"></p>
106
+ <h2 data-field="name"></h2>
107
+ <p data-field="description"></p>
108
+ <cl-price data-code><cl-price-amount type="compare-at"></cl-price-amount> <cl-price-amount type="price"></cl-price-amount></cl-price>
109
+ <cl-availability data-code><cl-availability-status type="available">In stock</cl-availability-status><cl-availability-status type="unavailable">Sold out</cl-availability-status></cl-availability>
110
+ <cl-add-to-cart data-code>Add to cart</cl-add-to-cart>
111
+ </div>
112
+ </article>
113
+ </template>
114
+ <template id="not-found-template">
115
+ <article class="product-page">
116
+ <a class="back" href="#">&larr; All products</a>
117
+ <p>This product is not in the store.</p>
118
+ </article>
119
+ </template>
120
+ <!-- The products, by sku: name, description, image_url, category. Keep it in step with the cards above. -->
121
+ <script type="application/json" id="products">
122
+ ${json}
123
+ </script>
140
124
  </body>
141
125
  </html>
142
126
  `;
143
127
  }
128
+ const STYLES = `/* The storefront's styles, in three parts: theme tokens, drop-in.js overrides, the page.
129
+ Restyle the page by changing the tokens; a dark theme is color-scheme: dark with paper and ink swapped.
130
+ index.html links this file after the drop-in.js stylesheets: the overrides use the same selectors as
131
+ drop-in.css, so they win by coming later. Keep that order. */
132
+
133
+ /* 1. Theme tokens */
134
+ :root {
135
+ color-scheme: light;
136
+ --paper: #ffffff; /* page background */
137
+ --ink: #16181d; /* text and prices */
138
+ --muted: #5d6572; /* secondary text, compare-at prices, availability */
139
+ --line: #e3e6ea; /* borders, disabled buttons */
140
+ --accent: #1f5eff; /* buttons, link hover */
141
+ --on-accent: #ffffff; /* text on the accent */
142
+ --radius: 8px;
143
+ --font: system-ui, sans-serif;
144
+ /* drop-in.css's own defaults (add to cart, cart count) read these. */
145
+ --cl-color-primary: var(--accent);
146
+ --cl-color-white: var(--on-accent);
147
+ }
148
+
149
+ /* 2. drop-in.js overrides */
150
+ /* drop-in.css colours cl-price-amount itself (#101111), so inheriting is not enough: set it here. */
151
+ cl-price-amount { color: var(--ink); font-weight: 600; }
152
+ cl-price-amount[type="compare-at"] { color: var(--muted); font-weight: 400; }
153
+ cl-availability { font-size: 13px; color: var(--muted); }
154
+ /* cl-add-to-cart is the button itself (it has no ::part); drop-in.css colours it with these same selectors. */
155
+ cl-add-to-cart { align-self: flex-start; padding: 10px 16px; border-radius: calc(var(--radius) - 2px); font: inherit; font-weight: 600; background-color: var(--line); color: var(--muted); }
156
+ cl-add-to-cart:not([aria-disabled=true]) { background-color: var(--accent); color: var(--on-accent); }
157
+ /* cl-checkout-link renders a plain <a> (no shadow DOM); without an order it is aria-disabled. */
158
+ cl-checkout-link a { display: inline-block; padding: 8px 14px; border-radius: calc(var(--radius) - 2px); background: var(--accent); color: var(--on-accent); font-weight: 600; text-decoration: none; }
159
+ cl-checkout-link[aria-disabled="true"] a { background: var(--line); color: var(--muted); }
160
+ /* drop-in.css styles the count as a badge beside a cart icon (absolute, pushed right). The link is text here, so render it inline: "Cart (3)". */
161
+ cl-cart-link cl-cart-count { position: static; transform: none; display: inline; min-width: 0; height: auto; padding: 0; border-radius: 0; background: none; color: inherit; font-size: inherit; }
162
+
163
+ /* 3. The page */
164
+ body { margin: 0; font-family: var(--font); color: var(--ink); background: var(--paper); }
165
+ a { color: inherit; }
166
+ header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; padding: 16px 24px; border-bottom: 1px solid var(--line); }
167
+ header h1 { margin: 0; font-size: 20px; }
168
+ header h1 a { text-decoration: none; }
169
+ /* The nav never wraps inside: on a narrow screen it moves under the store name as a whole. */
170
+ header nav { display: flex; align-items: center; gap: 16px; margin-left: auto; white-space: nowrap; }
171
+ header select { font: inherit; color: inherit; background: var(--paper); border: 1px solid var(--line); border-radius: calc(var(--radius) / 2); padding: 4px 6px; }
172
+ nav.categories { display: flex; flex-wrap: wrap; gap: 8px 20px; padding: 14px 24px; border-bottom: 1px solid var(--line); }
173
+ nav.categories a { text-decoration: none; font-size: 15px; }
174
+ nav.categories a:hover { color: var(--accent); }
175
+ main { max-width: 1100px; margin: 0 auto; padding: 32px 24px; }
176
+ main section { scroll-margin-top: 16px; }
177
+ main section + section { margin-top: 40px; }
178
+ main section > h2 { margin: 0 0 16px; font-size: 22px; }
179
+ .grid { display: grid; gap: 24px; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
180
+ .product { display: flex; flex-direction: column; gap: 8px; padding: 20px; border: 1px solid var(--line); border-radius: var(--radius); scroll-margin-top: 16px; }
181
+ .product h2, .product h3 { margin: 0; font-size: 17px; }
182
+ .product h2 a, .product h3 a { text-decoration: none; }
183
+ .product h2 a:hover, .product h3 a:hover { color: var(--accent); }
184
+ .product p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; }
185
+ .product cl-add-to-cart { margin-top: auto; }
186
+ .media img, .media .placeholder { display: block; width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: calc(var(--radius) - 2px); }
187
+ /* No image: a plain tile a shade off the page, in any theme. */
188
+ .placeholder { background: color-mix(in srgb, var(--ink) 6%, var(--paper)); }
189
+ .product-page { display: grid; gap: 32px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; }
190
+ .product-page .back { grid-column: 1 / -1; text-decoration: none; color: var(--muted); }
191
+ .product-page .back:hover { color: var(--accent); }
192
+ .product-page .info { display: flex; flex-direction: column; gap: 12px; }
193
+ .product-page .info h2 { margin: 0; font-size: 28px; }
194
+ .product-page .info p { margin: 0; line-height: 1.6; }
195
+ .product-page .category { color: var(--muted); font-size: 14px; }
196
+ footer { padding: 24px; border-top: 1px solid var(--line); color: var(--muted); font-size: 13px; }
197
+ @media (max-width: 720px) {
198
+ .product-page { grid-template-columns: 1fr; }
199
+ }
200
+ @media (max-width: 480px) {
201
+ header, nav.categories { padding-left: 16px; padding-right: 16px; }
202
+ header nav { gap: 12px; }
203
+ .market-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
204
+ main { padding: 24px 16px; }
205
+ }
206
+ `;
207
+ const APP = `// The storefront's behaviour, no build step: the market switch, the category links,
208
+ // and the product page at #/p/<sku>, filled from #product-template and the #products data.
209
+ // drop-in.js does the commerce: prices, availability, cart, checkout.
210
+
211
+ (function () {
212
+ var products = JSON.parse(document.getElementById('products').textContent);
213
+ var listing = document.getElementById('listing');
214
+ var page = document.getElementById('product-page');
215
+ var storeTitle = document.title;
216
+
217
+ // Market: ?market=<code> picks the drop-in.js scope (see the config block in index.html).
218
+ var select = document.getElementById('market');
219
+ select.value = market;
220
+ select.addEventListener('change', function () {
221
+ location.search = '?market=' + encodeURIComponent(select.value);
222
+ });
223
+
224
+ function fill(root, product, sku) {
225
+ root.querySelectorAll('[data-field]').forEach(function (el) {
226
+ var value = product[el.dataset.field];
227
+ if (el.tagName === 'IMG') {
228
+ if (!value) return el.remove();
229
+ el.src = value;
230
+ el.alt = product.name;
231
+ if (el.nextElementSibling) el.nextElementSibling.remove();
232
+ } else if (value) el.textContent = value;
233
+ else el.remove();
234
+ });
235
+ // code is set before the elements join the page, so drop-in.js reads it when they connect.
236
+ root.querySelectorAll('[data-code]').forEach(function (el) {
237
+ el.setAttribute('code', sku);
238
+ el.removeAttribute('data-code');
239
+ });
240
+ }
241
+
242
+ function showProduct(sku) {
243
+ var product = products[sku];
244
+ var view = document.getElementById(product ? 'product-template' : 'not-found-template').content.cloneNode(true);
245
+ if (product) fill(view, product, sku);
246
+ page.replaceChildren(view);
247
+ listing.hidden = true;
248
+ page.hidden = false;
249
+ document.title = (product ? product.name + ' · ' : '') + storeTitle;
250
+ window.scrollTo(0, 0);
251
+ }
252
+
253
+ function showListing(anchor) {
254
+ page.hidden = true;
255
+ page.replaceChildren();
256
+ listing.hidden = false;
257
+ document.title = storeTitle;
258
+ var target = anchor && document.getElementById(anchor);
259
+ if (target) target.scrollIntoView();
260
+ }
261
+
262
+ var lastSku = null;
263
+ function route() {
264
+ var hash = decodeURIComponent(location.hash.slice(1));
265
+ if (hash.indexOf('/p/') === 0) {
266
+ lastSku = hash.slice(3);
267
+ return showProduct(lastSku);
268
+ }
269
+ // A category link (#category-...), or the listing (#): back from a product, return to its card.
270
+ showListing(hash || (lastSku && 'p-' + lastSku));
271
+ lastSku = null;
272
+ }
273
+
274
+ window.addEventListener('hashchange', route);
275
+ route();
276
+ })();
277
+ `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commercelayer/agent-cli",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "c11r: the Commerce Layer CLI for coding agents. Builds a sandbox store that people and shopping agents can buy from.",
5
5
  "homepage": "https://commercelayer.ai",
6
6
  "keywords": [
@@ -35,6 +35,7 @@
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^22.0.0",
38
+ "happy-dom": "^21.0.4",
38
39
  "tsx": "^4.19.0",
39
40
  "typescript": "^5.6.0",
40
41
  "vitest": "^3.0.0"