@commercelayer/agent-cli 0.3.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -9
- package/dist/api/client.js +74 -1
- package/dist/api/stub/index.js +26 -1
- package/dist/api/stub/locale.js +52 -0
- package/dist/api/stub/management.js +38 -2
- package/dist/api/stub/orders.js +359 -0
- package/dist/api/stub/search.js +78 -0
- package/dist/api/stub/shopping.js +84 -18
- package/dist/api/stub/theme.js +119 -30
- package/dist/api/stub/ucp.js +10 -6
- package/dist/api/transport.js +3 -0
- package/dist/bin.js +18 -7
- package/dist/catalog.js +40 -4
- package/dist/commands.js +155 -25
- package/dist/files.js +14 -2
- package/dist/languages.js +49 -0
- package/dist/locales.js +43 -0
- package/dist/output.js +14 -1
- package/dist/products.js +15 -5
- package/dist/store.js +95 -9
- package/dist/ucp/shop.js +53 -6
- package/dist/version.js +9 -0
- package/package.json +1 -1
package/dist/api/stub/theme.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// at /products/<sku>. Prices come from what is applied; "Buy" opens the cart permalink.
|
|
5
5
|
// A product without an image shows its tile, and an unset hero the generated one, both
|
|
6
6
|
// drawn at /tiles/ (renderTile) as the store host does.
|
|
7
|
+
import { baseLanguage, cookieLocale, COOKIE, localeOfCountry, localesOf, texts } from './locale.js';
|
|
7
8
|
export const esc = (t) => String(t ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
8
9
|
/** Black or white, whichever reads better on the accent. */
|
|
9
10
|
function onAccent(hex) {
|
|
@@ -11,28 +12,126 @@ function onAccent(hex) {
|
|
|
11
12
|
const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
|
|
12
13
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.179 ? '#000000' : '#ffffff';
|
|
13
14
|
}
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
/** The selector's strings, in the store's base language; other languages fall back to en, like the store. */
|
|
16
|
+
const UI = {
|
|
17
|
+
en: { choose: 'Choose your country', shipping: (c) => `Shipping to ${c}?` },
|
|
18
|
+
it: { choose: 'Scegli il tuo paese', shipping: (c) => `Spedisci in ${c}?` },
|
|
19
|
+
fr: { choose: 'Choisissez votre pays', shipping: (c) => `Livraison en ${c} ?` },
|
|
20
|
+
de: { choose: 'Wählen Sie Ihr Land', shipping: (c) => `Versand nach ${c}?` },
|
|
21
|
+
es: { choose: 'Elige tu país', shipping: (c) => `¿Envío a ${c}?` },
|
|
22
|
+
};
|
|
23
|
+
const countryName = (cc, ll) => {
|
|
24
|
+
try {
|
|
25
|
+
return new Intl.DisplayNames([ll], { type: 'region' }).of(cc) ?? cc;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return cc;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const tag = (l) => `${l.language}-${l.country}`;
|
|
32
|
+
const alternates = (locales, rest) => [...locales.map((l) => `<link rel="alternate" hreflang="${tag(l)}" href="/${l.path}${rest === '/' ? '/' : rest}">`), '<link rel="alternate" hreflang="x-default" href="/">'].join('\n');
|
|
33
|
+
/**
|
|
34
|
+
* The hosted theme's routes. With locales (two or more countries, or one): the selector at /,
|
|
35
|
+
* the pages under /<cc>-<ll>/, a cookie that remembers the choice, unprefixed product and
|
|
36
|
+
* category paths sent to the remembered or first locale. Without (not applied since locales
|
|
37
|
+
* exist): the site as it was. null when the path is not the theme's.
|
|
38
|
+
*/
|
|
39
|
+
export function renderTheme(r, path, search, headers = {}) {
|
|
40
|
+
const locales = localesOf(r);
|
|
41
|
+
if (!locales.length)
|
|
42
|
+
return renderPage(r, path, search, null);
|
|
43
|
+
const redirect = (to, extra = {}) => ({ status: 302, headers: { location: to, 'cache-control': 'no-store', ...extra }, body: '' });
|
|
44
|
+
if (path === '/') {
|
|
45
|
+
if (locales.length === 1)
|
|
46
|
+
return redirect(`/${locales[0].path}/`);
|
|
47
|
+
const chosen = cookieLocale(r, headers.cookie);
|
|
48
|
+
if (chosen && !search.has('change'))
|
|
49
|
+
return redirect(`/${chosen.path}/`, { vary: 'Cookie' });
|
|
50
|
+
const page = selector(r, locales, headers['cf-ipcountry']);
|
|
51
|
+
page.headers.vary = 'Cookie';
|
|
52
|
+
return page;
|
|
53
|
+
}
|
|
54
|
+
const m = path.match(/^\/([a-z]{2})-([a-z]{2})(\/.*)?$/);
|
|
55
|
+
if (m) {
|
|
56
|
+
const l = localeOfCountry(r, m[1]);
|
|
57
|
+
if (!l)
|
|
58
|
+
return null;
|
|
59
|
+
const rest = m[3] ?? '/';
|
|
60
|
+
if (l.language !== m[2])
|
|
61
|
+
return redirect(`/${l.path}${rest === '/' ? '/' : rest}`);
|
|
62
|
+
const page = renderPage(r, rest, search, l);
|
|
63
|
+
if (page)
|
|
64
|
+
page.headers['set-cookie'] = `${COOKIE}=${l.path}; Path=/; Max-Age=31536000; SameSite=Lax; Secure`;
|
|
65
|
+
return page;
|
|
66
|
+
}
|
|
67
|
+
if (/^\/(products|categories)\//.test(path))
|
|
68
|
+
return redirect(`/${(cookieLocale(r, headers.cookie) ?? locales[0]).path}${path}`, { vary: 'Cookie' });
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
function selector(r, locales, ipCountry) {
|
|
72
|
+
const base = baseLanguage(r);
|
|
73
|
+
const ui = UI[base] ?? UI.en;
|
|
16
74
|
const markets = Object.values(r.applied?.markets ?? {});
|
|
75
|
+
const near = ipCountry ? localeOfCountry(r, ipCountry) : undefined;
|
|
76
|
+
const link = (l) => `<a href="/${l.path}/" hreflang="${tag(l)}" lang="${l.language}">${esc(countryName(l.country, base))}</a>`;
|
|
77
|
+
const body = `<main class="selector"><h1>${esc(ui.choose)}</h1>${near ? `<p class="suggestion"><a href="/${near.path}/">${esc(ui.shipping(countryName(near.country, base)))}</a></p>` : ''}${markets
|
|
78
|
+
.map((mk) => {
|
|
79
|
+
const ls = locales.filter((l) => l.market === mk.code);
|
|
80
|
+
return ls.length ? `<section><h2>${esc(mk.name)} (${esc(mk.currency)})</h2><ul>${ls.map((l) => `<li>${link(l)}</li>`).join('')}</ul></section>` : '';
|
|
81
|
+
})
|
|
82
|
+
.join('')}</main>`;
|
|
83
|
+
return page(r, { lang: base, title: r.name ?? r.org, head: alternates(locales, '/'), header: '', body, description: ui.choose });
|
|
84
|
+
}
|
|
85
|
+
function page(r, o) {
|
|
17
86
|
const theme = r.theme ?? {};
|
|
18
87
|
const color = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(theme.color ?? '') ? theme.color : '#2f5d50';
|
|
19
88
|
const name = r.name ?? r.org;
|
|
20
|
-
const market = markets.find((m) => m.code === search.get('market')) ?? markets[0];
|
|
21
|
-
const keep = market && market !== markets[0] ? `?market=${encodeURIComponent(market.code)}` : '';
|
|
22
|
-
const price = (p) => (market && p.prices[market.currency] !== undefined ? `${market.currency} ${p.prices[market.currency].toFixed(2)}` : '');
|
|
23
|
-
const tagline = theme.tagline ?? `Ships to ${markets.flatMap((m) => m.countries).join(', ')}`;
|
|
24
89
|
const badge = theme.badge ? ` <span class="badge">${esc(theme.badge)}</span>` : '';
|
|
25
90
|
const logo = (theme.logo ? `<img class="logo" src="/${esc(theme.logo.replace(/^\//, ''))}" alt="${esc(name)}">` : `<span class="wordmark">${esc(name)}</span>`) + badge;
|
|
91
|
+
const html = `<!doctype html>
|
|
92
|
+
<html lang="${esc(o.lang)}">
|
|
93
|
+
<head>
|
|
94
|
+
<meta charset="utf-8">
|
|
95
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
96
|
+
<title>${esc(o.title)}</title>
|
|
97
|
+
<meta name="description" content="${esc(o.description)}">
|
|
98
|
+
${o.head ? `${o.head}\n` : ''}<!-- c11r stub: a stand-in for the hosted theme. -->
|
|
99
|
+
<style>:root{--accent:${color};--on-accent:${onAccent(color)}}body{margin:0;font-family:system-ui,sans-serif;color:#16181d}header{display:flex;justify-content:space-between;padding:16px 24px}.logo{max-height:40px}.badge{margin-left:8px;padding:2px 8px;border-radius:999px;background:var(--accent);color:var(--on-accent);font-size:12px;vertical-align:middle}.hero{padding:64px 24px;background-size:cover}.band{background:var(--accent);color:var(--on-accent)}.grid{display:grid;gap:24px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))}.buy{background:var(--accent);color:var(--on-accent);padding:8px 14px}main{padding:24px}</style>
|
|
100
|
+
</head>
|
|
101
|
+
<body>
|
|
102
|
+
<header><a href="${o.home ?? '/'}">${logo}</a>${o.header}</header>
|
|
103
|
+
${o.body}
|
|
104
|
+
</body>
|
|
105
|
+
</html>
|
|
106
|
+
`;
|
|
107
|
+
return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: html };
|
|
108
|
+
}
|
|
109
|
+
/** The home page and the product pages, for a locale (prefixed paths, its market, its language) or for none (the site as it was). */
|
|
110
|
+
function renderPage(r, path, search, locale) {
|
|
111
|
+
const products = Object.values(r.applied?.products ?? {});
|
|
112
|
+
const markets = Object.values(r.applied?.markets ?? {});
|
|
113
|
+
const theme = r.theme ?? {};
|
|
114
|
+
const name = r.name ?? r.org;
|
|
115
|
+
const base = baseLanguage(r);
|
|
116
|
+
const ll = locale?.language ?? base;
|
|
117
|
+
const market = locale ? r.applied?.markets[locale.market] : (markets.find((m) => m.code === search.get('market')) ?? markets[0]);
|
|
118
|
+
const prefix = locale ? `/${locale.path}` : '';
|
|
119
|
+
const keep = !locale && market && market !== markets[0] ? `?market=${encodeURIComponent(market.code)}` : '';
|
|
120
|
+
// Under a locale, prices as that country writes them (14,50 € on /it-it/); the site as it was keeps EUR 14.50.
|
|
121
|
+
const money = (amount, currency) => (locale ? new Intl.NumberFormat(tag(locale), { style: 'currency', currency }).format(amount) : `${currency} ${amount.toFixed(2)}`);
|
|
122
|
+
const price = (p) => (market && p.prices[market.currency] !== undefined ? money(p.prices[market.currency], market.currency) : '');
|
|
123
|
+
const tagline = theme.tagline ?? `Ships to ${markets.flatMap((m) => m.countries).join(', ')}`;
|
|
26
124
|
const img = (url) => (/^https?:/.test(url) ? url : `/${url}`);
|
|
27
|
-
const
|
|
28
|
-
const
|
|
125
|
+
const lang = ll === base ? '' : `?lang=${ll}`;
|
|
126
|
+
const image = (p) => p.image_url ? `<img src="${esc(img(p.image_url))}" alt="${esc(texts(p, ll).name)}">` : `<span class="placeholder"><img src="/tiles/${esc(encodeURIComponent(p.sku))}.svg${lang}" alt="${esc(texts(p, ll).name)}" loading="lazy"></span>`;
|
|
127
|
+
const card = (p) => `<article class="product"><a href="${prefix}/products/${encodeURIComponent(p.sku)}${keep}">${image(p)}<h3>${esc(texts(p, ll).name)}</h3></a><p class="price">${esc(price(p))}</p><a class="buy" href="/buy/${encodeURIComponent(p.sku)}:1">Buy</a></article>`;
|
|
29
128
|
let title = name;
|
|
30
129
|
let body;
|
|
31
130
|
const m = path.match(/^\/products\/([^/]+)$/);
|
|
32
131
|
if (path === '/') {
|
|
33
132
|
const groups = new Map();
|
|
34
133
|
for (const p of products) {
|
|
35
|
-
const top = p.category?.split(' > ')[0] ?? 'Other';
|
|
134
|
+
const top = texts(p, ll).category?.split(' > ')[0] ?? 'Other';
|
|
36
135
|
groups.set(top, [...(groups.get(top) ?? []), p]);
|
|
37
136
|
}
|
|
38
137
|
const hero = `<section class="hero" style="background-image:url('${esc(theme.hero ? img(theme.hero) : '/tiles/hero.svg')}')">`;
|
|
@@ -42,32 +141,22 @@ export function renderTheme(r, path, search) {
|
|
|
42
141
|
const p = r.applied?.products[decodeURIComponent(m[1])];
|
|
43
142
|
if (!p)
|
|
44
143
|
return null;
|
|
45
|
-
|
|
46
|
-
|
|
144
|
+
const t = texts(p, ll);
|
|
145
|
+
title = `${t.name} · ${name}`;
|
|
146
|
+
body = `<main class="product-page">${image(p)}<div>${t.category ? `<p class="category">${esc(t.category)}</p>` : ''}<h1>${esc(t.name)}</h1>${t.description ? `<p>${esc(t.description)}</p>` : ''}<p class="price">${esc(price(p))}</p><a class="buy" href="/buy/${encodeURIComponent(p.sku)}:1">Buy</a></div></main>`;
|
|
47
147
|
}
|
|
48
148
|
else
|
|
49
149
|
return null;
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
<meta name="description" content="${esc(tagline)}">
|
|
57
|
-
<!-- c11r stub: a stand-in for the hosted theme. -->
|
|
58
|
-
<style>:root{--accent:${color};--on-accent:${onAccent(color)}}body{margin:0;font-family:system-ui,sans-serif;color:#16181d}header{display:flex;justify-content:space-between;padding:16px 24px}.logo{max-height:40px}.badge{margin-left:8px;padding:2px 8px;border-radius:999px;background:var(--accent);color:var(--on-accent);font-size:12px;vertical-align:middle}.hero{padding:64px 24px;background-size:cover}.band{background:var(--accent);color:var(--on-accent)}.grid{display:grid;gap:24px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))}.buy{background:var(--accent);color:var(--on-accent);padding:8px 14px}main{padding:24px}</style>
|
|
59
|
-
</head>
|
|
60
|
-
<body>
|
|
61
|
-
<header><a href="/${keep}">${logo}</a><nav>${markets.map((x) => `<a href="?market=${encodeURIComponent(x.code)}">${esc(x.name)}</a>`).join(' ')}</nav></header>
|
|
62
|
-
${body}
|
|
63
|
-
</body>
|
|
64
|
-
</html>
|
|
65
|
-
`;
|
|
66
|
-
return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: html };
|
|
150
|
+
const header = locale
|
|
151
|
+
? localesOf(r).length > 1
|
|
152
|
+
? `<nav><a class="country" href="/?change">${esc(countryName(locale.country, ll))} · ${esc(market?.currency ?? '')}</a></nav>`
|
|
153
|
+
: ''
|
|
154
|
+
: `<nav>${markets.map((x) => `<a href="?market=${encodeURIComponent(x.code)}">${esc(x.name)}</a>`).join(' ')}</nav>`;
|
|
155
|
+
return page(r, { lang: locale ? tag(locale) : 'en', title, head: locale ? alternates(localesOf(r), path) : '', header, body, description: tagline, home: `${prefix}/${keep}` });
|
|
67
156
|
}
|
|
68
157
|
const xml = (t) => String(t ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
69
158
|
/** /tiles/<sku>.svg and /tiles/hero.svg: a plain stand-in for the store host's generated tiles. */
|
|
70
|
-
export function renderTile(r, path) {
|
|
159
|
+
export function renderTile(r, path, search) {
|
|
71
160
|
const m = path.match(/^\/tiles\/([^/]+)\.svg$/);
|
|
72
161
|
if (!m)
|
|
73
162
|
return null;
|
|
@@ -87,7 +176,7 @@ export function renderTile(r, path) {
|
|
|
87
176
|
const p = r.applied?.products[sku];
|
|
88
177
|
if (!p)
|
|
89
178
|
return null;
|
|
90
|
-
svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" viewBox="0 0 1200 1200"><rect width="1200" height="1200" fill="${color}" fill-opacity="0.08"/><text x="600" y="600" text-anchor="middle" font-family="system-ui, sans-serif" font-size="72" fill="${color}">${xml(p.name.slice(0, 60))}</text></svg>`;
|
|
179
|
+
svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" viewBox="0 0 1200 1200"><rect width="1200" height="1200" fill="${color}" fill-opacity="0.08"/><text x="600" y="600" text-anchor="middle" font-family="system-ui, sans-serif" font-size="72" fill="${color}">${xml(texts(p, search?.get('lang') ?? '').name.slice(0, 60))}</text></svg>`;
|
|
91
180
|
}
|
|
92
181
|
return { status: 200, headers, body: svg };
|
|
93
182
|
}
|
package/dist/api/stub/ucp.js
CHANGED
|
@@ -9,6 +9,7 @@ import { strip } from './db.js';
|
|
|
9
9
|
import { json } from './http.js';
|
|
10
10
|
import * as shop from './shopping.js';
|
|
11
11
|
import { esc, renderTheme, renderTile } from './theme.js';
|
|
12
|
+
import { ucpLanguage } from './locale.js';
|
|
12
13
|
const SCOPES = ['dev.ucp.shopping.order:read', 'dev.ucp.shopping.order:manage'];
|
|
13
14
|
const OUR_CAPABILITIES = [...SHOPPING_CAPABILITIES, 'dev.ucp.common.ask', ...Object.keys(EXTENSIONS), 'dev.ucp.common.identity_linking', 'dev.ucp.shopping.permalink'];
|
|
14
15
|
// ---------- profile
|
|
@@ -107,8 +108,11 @@ function handleService(req, r, origin, path) {
|
|
|
107
108
|
return { status: seen.status, headers: { ...seen.headers, 'idempotent-replayed': 'true' }, body: seen.body };
|
|
108
109
|
}
|
|
109
110
|
}
|
|
110
|
-
// 3. The operation.
|
|
111
|
+
// 3. The operation, in the language the platform asked for (or the buyer's country's, or the base).
|
|
112
|
+
ctx.language = ucpLanguage(r, parsed?.context, req.headers['accept-language']);
|
|
113
|
+
ctx.country = typeof parsed?.context?.address_country === 'string' ? parsed.context.address_country : undefined;
|
|
111
114
|
const res = dispatch(ctx, req.method, path, parsed);
|
|
115
|
+
res.headers['content-language'] = ctx.language;
|
|
112
116
|
if (mutating && key)
|
|
113
117
|
r.ucp.idempotency[`${req.method} ${path} ${key}`] = { digest, status: res.status, headers: res.headers, body: res.body };
|
|
114
118
|
return res;
|
|
@@ -303,21 +307,21 @@ function handlePermalink(req, r, origin, path) {
|
|
|
303
307
|
return { status: 303, headers: { location: target, ...safe }, body: '' };
|
|
304
308
|
}
|
|
305
309
|
const MIME = { html: 'text/html; charset=utf-8', css: 'text/css', js: 'text/javascript', mjs: 'text/javascript', json: 'application/json', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', avif: 'image/avif', ico: 'image/x-icon', txt: 'text/plain' };
|
|
306
|
-
function serveStorefront(r, path, search) {
|
|
310
|
+
function serveStorefront(r, path, search, headers) {
|
|
307
311
|
const files = r.ucp.files;
|
|
308
312
|
// /tiles/ is the store host's own, in front of a custom storefront too: unknown tiles are a 404.
|
|
309
313
|
if (path.startsWith('/tiles/'))
|
|
310
|
-
return renderTile(r, path) ?? html(404, 'Not found', `<p>No ${path} on this store.</p>`);
|
|
314
|
+
return renderTile(r, path, search) ?? html(404, 'Not found', `<p>No ${path} on this store.</p>`);
|
|
311
315
|
// Agent-only: no pages for humans beyond what UCP needs; the images stay, UCP's media point at them.
|
|
312
316
|
const agentOnly = r.storefront_mode === 'agent-only';
|
|
313
317
|
// Links shared while the store was hosted land on the agent page.
|
|
314
|
-
if (agentOnly &&
|
|
318
|
+
if (agentOnly && /^(\/[a-z]{2}-[a-z]{2})?\/(products|categories)\/./.test(path))
|
|
315
319
|
return { status: 302, headers: { location: '/' }, body: '' };
|
|
316
320
|
if (agentOnly && path === '/')
|
|
317
321
|
return html(200, r.name ?? r.org, `<p>${esc(r.name ?? r.org)} sells to shopping agents over UCP: <a href="/.well-known/ucp">/.well-known/ucp</a>.</p>`);
|
|
318
322
|
// A custom storefront (an uploaded index.html) serves every path; otherwise the theme renders its routes.
|
|
319
323
|
if (!agentOnly && files['index.html'] === undefined) {
|
|
320
|
-
const page = renderTheme(r, path, search);
|
|
324
|
+
const page = renderTheme(r, path, search, headers);
|
|
321
325
|
if (page)
|
|
322
326
|
return page;
|
|
323
327
|
}
|
|
@@ -365,7 +369,7 @@ export function handleUcp(req, r) {
|
|
|
365
369
|
return html(404, 'Not found', `<p>No ${kind} ${m[2]}.</p>`);
|
|
366
370
|
return html(200, `${kind === 'order' ? `Order ${found.label}` : `Your ${kind}`}`, `<p>${kind === 'order' ? 'Thank you. ' : ''}${found.line_items.length} line item(s), ${found.currency} ${(found.totals.at(-1).amount / 100).toFixed(2)}.</p>`);
|
|
367
371
|
}
|
|
368
|
-
return serveStorefront(r, path, req.url.searchParams);
|
|
372
|
+
return serveStorefront(r, path, req.url.searchParams, req.headers);
|
|
369
373
|
}
|
|
370
374
|
/** Lets the service read the bearer token for order authorization. */
|
|
371
375
|
export function bearerOf(req) {
|
package/dist/api/transport.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// stands in for the whole platform.
|
|
6
6
|
import { fail } from '../output.js';
|
|
7
7
|
import { handle } from './stub/index.js';
|
|
8
|
+
import { USER_AGENT } from '../version.js';
|
|
8
9
|
export const DEFAULT_API_URL = 'https://api.commercelayer.ai';
|
|
9
10
|
export const apiUrl = () => process.env.C11R_API_URL ?? DEFAULT_API_URL;
|
|
10
11
|
export async function send(method, url, headers = {}, body) {
|
|
@@ -12,6 +13,8 @@ export async function send(method, url, headers = {}, body) {
|
|
|
12
13
|
const lower = {};
|
|
13
14
|
for (const [k, v] of Object.entries(headers))
|
|
14
15
|
lower[k.toLowerCase()] = v;
|
|
16
|
+
// On every request, to the API and to stores: the API tells an outdated CLI so (cli_outdated).
|
|
17
|
+
lower['user-agent'] ??= USER_AGENT;
|
|
15
18
|
if (body !== undefined && !lower['content-type'])
|
|
16
19
|
lower['content-type'] = 'application/json';
|
|
17
20
|
if (base === 'stub')
|
package/dist/bin.js
CHANGED
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
// Output is always JSON; exit code 1 on an error or a failed check.
|
|
5
5
|
import * as cmd from './commands.js';
|
|
6
6
|
import { CliError, print } from './output.js';
|
|
7
|
-
|
|
7
|
+
import { LATEST_HINT, NPX } from './version.js';
|
|
8
|
+
const VALUE_OPTIONS = new Set(['--name', '--sku', '--quantity', '--country', '--code', '--file', '--store', '--item', '--email', '--first-name', '--last-name', '--phone', '--street', '--city', '--postal-code', '--region', '--shipping', '--category', '--min-price', '--max-price', '--status', '--payment', '--fulfillment', '--limit', '--archived', '--amount', '--carrier', '--tracking', '--tracking-url']);
|
|
8
9
|
const argv = process.argv.slice(2);
|
|
9
10
|
const flags = new Set();
|
|
10
11
|
const values = {};
|
|
11
|
-
/** Every value of an option given more than once (--item, --code), in order; values keeps the last. */
|
|
12
|
+
/** Every value of an option given more than once (--item, --code, --category), in order; values keeps the last. */
|
|
12
13
|
const lists = {};
|
|
13
14
|
const set = (k, v) => {
|
|
14
15
|
values[k] = v;
|
|
@@ -36,7 +37,7 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
36
37
|
const [name, sub] = args;
|
|
37
38
|
async function run() {
|
|
38
39
|
if (flags.has('--version') || name === 'version')
|
|
39
|
-
return { version: cmd.VERSION };
|
|
40
|
+
return { version: cmd.VERSION, latest_hint: LATEST_HINT };
|
|
40
41
|
switch (name) {
|
|
41
42
|
case undefined:
|
|
42
43
|
case 'help':
|
|
@@ -93,17 +94,27 @@ async function run() {
|
|
|
93
94
|
if (sub === 'test')
|
|
94
95
|
return cmd.ordersTest(flags, values);
|
|
95
96
|
if (sub === 'list')
|
|
96
|
-
return cmd.ordersList();
|
|
97
|
+
return cmd.ordersList(values, flags);
|
|
97
98
|
if (sub === 'show')
|
|
98
99
|
return cmd.ordersShow(args[2]);
|
|
99
|
-
|
|
100
|
+
if (sub === 'approve' || sub === 'capture' || sub === 'cancel' || sub === 'refund' || sub === 'archive' || sub === 'unarchive')
|
|
101
|
+
return cmd.ordersAct(sub, args[2], values, flags);
|
|
102
|
+
throw new CliError('unknown_command', `There is no command "orders ${sub ?? ''}".`, 'Use `c11r orders list`, `show <number>`, `approve <number> [--capture]`, `capture`, `cancel`, `refund <number> [--amount <n>]`, `archive`, `unarchive`, or `orders test`.');
|
|
103
|
+
case 'fulfillment':
|
|
104
|
+
if (sub === 'list')
|
|
105
|
+
return cmd.fulfillmentList(values);
|
|
106
|
+
if (sub === 'show')
|
|
107
|
+
return cmd.fulfillmentShow(args[2]);
|
|
108
|
+
if (sub === 'hold' || sub === 'pick' || sub === 'pack' || sub === 'ready' || sub === 'ship' || sub === 'deliver')
|
|
109
|
+
return cmd.fulfillmentAct(sub, args[2], values);
|
|
110
|
+
throw new CliError('unknown_command', `There is no command "fulfillment ${sub ?? ''}".`, 'Use `c11r fulfillment list`, `show <shipment>`, `hold`, `pick`, `pack`, `ready`, `ship <shipment> [--carrier --tracking --tracking-url]` or `deliver`.');
|
|
100
111
|
case 'ucp':
|
|
101
112
|
if (sub === 'profile')
|
|
102
113
|
return cmd.ucpProfile(flags);
|
|
103
114
|
if (sub === 'check')
|
|
104
115
|
return cmd.ucpCheck();
|
|
105
116
|
if (sub === 'search')
|
|
106
|
-
return cmd.ucpSearch(args.slice(2).join(' '), values);
|
|
117
|
+
return cmd.ucpSearch(args.slice(2).join(' '), values, lists);
|
|
107
118
|
if (sub === 'product')
|
|
108
119
|
return cmd.ucpProduct(args[2], values);
|
|
109
120
|
if (sub === 'order')
|
|
@@ -141,7 +152,7 @@ run()
|
|
|
141
152
|
}
|
|
142
153
|
else {
|
|
143
154
|
const message = e instanceof Error ? e.message : String(e);
|
|
144
|
-
print({ error: { code: 'internal', message, hint:
|
|
155
|
+
print({ error: { code: 'internal', message, hint: `This is a bug in the CLI. Tell the human, with this output, and try again with ${NPX} in case it is already fixed.` } });
|
|
145
156
|
}
|
|
146
157
|
process.exit(1);
|
|
147
158
|
});
|
package/dist/catalog.js
CHANGED
|
@@ -9,7 +9,8 @@ import YAML from 'yaml';
|
|
|
9
9
|
import { credential, paths } from './files.js';
|
|
10
10
|
import { CliError, fail } from './output.js';
|
|
11
11
|
import { parseCsv, readProductFile, toCsv } from './products.js';
|
|
12
|
-
import {
|
|
12
|
+
import { ISO_639_1, setPlain, setTranslated, translatedColumn, TRANSLATED } from './languages.js';
|
|
13
|
+
import { canonical, checkProducts, readStoreFile, storeLanguage } from './store.js';
|
|
13
14
|
export const SOURCES = ['supabase', 'sheets', 'merchant'];
|
|
14
15
|
export const catalogFile = path.join(paths.products, 'catalog.csv');
|
|
15
16
|
const FIELDS = ['sku', 'name', 'description', 'image_url', 'category'];
|
|
@@ -33,7 +34,21 @@ function readConfig(doc) {
|
|
|
33
34
|
? 'Set url to the Google Sheet\'s CSV link (File > Share > Publish to web > Comma-separated values), or to the sheet\'s own link if it is shared with anyone who has the link.'
|
|
34
35
|
: 'Set url to the Merchant Center product feed (RSS/XML or TSV), as Merchant Center fetches it.');
|
|
35
36
|
}
|
|
36
|
-
|
|
37
|
+
const translations = {};
|
|
38
|
+
if (c.translations !== undefined && c.translations !== null) {
|
|
39
|
+
if (c.source !== 'merchant')
|
|
40
|
+
fail('invalid_catalog', `catalog.translations is for merchant feeds; this source is ${c.source}.`, 'In a sheet or a table, translations are columns: name_it, description_it, category_it.');
|
|
41
|
+
if (typeof c.translations !== 'object' || Array.isArray(c.translations))
|
|
42
|
+
fail('invalid_catalog', 'catalog.translations in store.yml is not a map.', 'Map each language to its feed: translations: { it: https://..., fr: https://... }.');
|
|
43
|
+
for (const [ll, u] of Object.entries(c.translations)) {
|
|
44
|
+
if (!ISO_639_1.has(ll))
|
|
45
|
+
fail('invalid_catalog', `catalog.translations.${ll} is not a language.`, 'Use ISO 639-1 codes in lower case: it, fr, de, ...');
|
|
46
|
+
if (!/^https?:\/\//.test(String(u)))
|
|
47
|
+
fail('invalid_catalog', `catalog.translations.${ll} is not a URL.`, 'Set it to the Merchant Center feed in that language (RSS/XML or TSV).');
|
|
48
|
+
translations[ll] = String(u);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { source: c.source, url, table, translations };
|
|
37
52
|
}
|
|
38
53
|
async function get(url, what, headers = {}) {
|
|
39
54
|
try {
|
|
@@ -51,13 +66,16 @@ function fromTable(header, rows, where, label, currencies) {
|
|
|
51
66
|
const key = h.trim().toLowerCase();
|
|
52
67
|
if (FIELDS.includes(key))
|
|
53
68
|
return { field: key };
|
|
69
|
+
const t = translatedColumn(key);
|
|
70
|
+
if (t)
|
|
71
|
+
return { translated: t.field, language: t.language };
|
|
54
72
|
if (key === '' || IGNORED.includes(key))
|
|
55
73
|
return null;
|
|
56
74
|
const code = /^[a-z]{3}$/.test(key);
|
|
57
75
|
if (code && (currencies.length === 0 || currencies.includes(key.toUpperCase())))
|
|
58
76
|
return { currency: key.toUpperCase() };
|
|
59
77
|
const why = key === 'stock' ? 'Stock is not part of the catalog: inventory is tracked live in Commerce Layer. ' : code ? `${key.toUpperCase()} is not the currency of a market in store.yml. ` : '';
|
|
60
|
-
fail('invalid_product', `${where} has a column "${h}" that is not a product field.`, `${why}The catalog columns are ${FIELDS.join(', ')} and one price column per market currency, named by its ISO 4217 code in lowercase (${prices}). Ask the human to rename or remove the column.`);
|
|
78
|
+
fail('invalid_product', `${where} has a column "${h}" that is not a product field.`, `${why}The catalog columns are ${FIELDS.join(', ')}, name_<ll>, description_<ll> and category_<ll> for other languages (name_it), and one price column per market currency, named by its ISO 4217 code in lowercase (${prices}). Ask the human to rename or remove the column.`);
|
|
61
79
|
});
|
|
62
80
|
if (!columns.some((c) => c && 'field' in c && c.field === 'sku'))
|
|
63
81
|
fail('invalid_product', `${where} has no sku column.`, `The catalog columns are ${FIELDS.join(', ')} and one price column per market currency (${prices}).`);
|
|
@@ -76,6 +94,10 @@ function fromTable(header, rows, where, label, currencies) {
|
|
|
76
94
|
fail('invalid_product', `${label(i)}: ${col.currency} price "${v}" is not a number.`, 'Write prices as plain decimals, such as 14.50, with no currency sign or thousands separator.');
|
|
77
95
|
product.prices[col.currency] = price;
|
|
78
96
|
}
|
|
97
|
+
else if ('translated' in col)
|
|
98
|
+
setTranslated(product, col.translated, col.language, String(v).trim());
|
|
99
|
+
else if (TRANSLATED.includes(col.field))
|
|
100
|
+
setPlain(product, col.field, String(v).trim());
|
|
79
101
|
else
|
|
80
102
|
product[col.field] = String(v).trim();
|
|
81
103
|
});
|
|
@@ -233,6 +255,20 @@ async function fromMerchant(c) {
|
|
|
233
255
|
bySku.set(item.id, sourced);
|
|
234
256
|
out.push(sourced);
|
|
235
257
|
});
|
|
258
|
+
// A feed per language: title, description and product_type of the same ids, in that language.
|
|
259
|
+
for (const [ll, url] of Object.entries(c.translations)) {
|
|
260
|
+
const res = await get(url, `the ${ll} Merchant feed`);
|
|
261
|
+
if (!res.ok)
|
|
262
|
+
fail('catalog_error', `The ${ll} Merchant feed answered ${res.status}.`, `Check catalog.translations.${ll} in store.yml: it is the feed URL Merchant Center fetches for that language.`);
|
|
263
|
+
for (const item of feedRows(await res.text()).items) {
|
|
264
|
+
const target = item.id ? bySku.get(item.id)?.product : undefined;
|
|
265
|
+
if (!target)
|
|
266
|
+
continue;
|
|
267
|
+
for (const [tag, field] of [['title', 'name'], ['description', 'description'], ['product_type', 'category']])
|
|
268
|
+
if (item[tag])
|
|
269
|
+
setTranslated(target, field, ll, item[tag]);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
236
272
|
return out;
|
|
237
273
|
}
|
|
238
274
|
export async function pull() {
|
|
@@ -254,7 +290,7 @@ export async function pull() {
|
|
|
254
290
|
const rls = config.source === 'supabase' ? ' If the table has rows, row level security hides them from the anon key: ask the human to add a policy that allows select.' : '';
|
|
255
291
|
fail('empty_catalog', `The ${config.source} catalog has no products.`, `Ask the human to add products to it, then pull again.${rls}`);
|
|
256
292
|
}
|
|
257
|
-
const products = checkProducts(sourced, currencies);
|
|
293
|
+
const products = checkProducts(sourced, currencies, storeLanguage(doc.language));
|
|
258
294
|
let before = new Map();
|
|
259
295
|
if (fs.existsSync(catalogFile)) {
|
|
260
296
|
try {
|