@blamejs/blamejs-shop 0.0.65 → 0.0.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/lib/assembly-instructions.js +777 -0
- package/lib/auto-replenish.js +933 -0
- package/lib/business-hours.js +980 -0
- package/lib/click-and-collect.js +711 -0
- package/lib/clickstream.js +713 -0
- package/lib/cost-layers.js +774 -0
- package/lib/credit-limits.js +752 -0
- package/lib/currency-rounding.js +525 -0
- package/lib/customer-activity.js +862 -0
- package/lib/customer-notes.js +712 -0
- package/lib/customer-risk-profile.js +593 -0
- package/lib/customer-surveys.js +1012 -0
- package/lib/damage-photos.js +473 -0
- package/lib/discount-allocation.js +557 -0
- package/lib/dropship-forwarding.js +645 -0
- package/lib/email-templates.js +817 -0
- package/lib/index.js +45 -0
- package/lib/inventory-allocations.js +559 -0
- package/lib/inventory-writeoffs.js +636 -0
- package/lib/knowledge-base.js +1104 -0
- package/lib/locale-router.js +1077 -0
- package/lib/operator-roles.js +768 -0
- package/lib/order-escalation.js +951 -0
- package/lib/order-ratings.js +495 -0
- package/lib/order-tags.js +944 -0
- package/lib/packing-slips.js +810 -0
- package/lib/payment-retries.js +816 -0
- package/lib/pick-lists.js +639 -0
- package/lib/pixel-events.js +995 -0
- package/lib/preorder.js +595 -0
- package/lib/print-queue.js +681 -0
- package/lib/product-qa.js +749 -0
- package/lib/promo-bundles.js +835 -0
- package/lib/push-notifications.js +937 -0
- package/lib/refund-automation.js +853 -0
- package/lib/reorder-reminders.js +798 -0
- package/lib/robots-config.js +753 -0
- package/lib/seller-signup.js +1052 -0
- package/lib/site-redirects.js +690 -0
- package/lib/sitemap-generator.js +717 -0
- package/lib/subscription-gifts.js +710 -0
- package/lib/tax-cert-renewals.js +632 -0
- package/lib/theme-assets.js +711 -0
- package/lib/tier-benefits.js +776 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/metrics.js +68 -4
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
- package/lib/wishlist-alerts.js +842 -0
- package/lib/wishlist-sharing.js +718 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1077 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.localeRouter
|
|
4
|
+
* @title Storefront locale routing — pick the right BCP-47 tag for
|
|
5
|
+
* an incoming request from URL prefix / subdomain / cookie /
|
|
6
|
+
* Accept-Language / customer preference.
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* The shop renders one storefront in many languages. An incoming
|
|
10
|
+
* request to the homepage of a multi-locale storefront answers a
|
|
11
|
+
* single question first: *which locale do I render this page in*.
|
|
12
|
+
* This primitive owns that question. The operator declares
|
|
13
|
+
*
|
|
14
|
+
* - a catalog of locales (`defineLocale`) — every BCP-47 tag the
|
|
15
|
+
* storefront knows about, classified `primary` / `regional` /
|
|
16
|
+
* `variant`, with an optional `fallback` chain for when a
|
|
17
|
+
* picked locale isn't supported by the active policy and a
|
|
18
|
+
* `currency` default that downstream display primitives use
|
|
19
|
+
* to pre-fill the buyer's currency selector;
|
|
20
|
+
*
|
|
21
|
+
* - a routing policy (`definePolicy`) — strategy + default
|
|
22
|
+
* locale + supported list. A storefront can carry multiple
|
|
23
|
+
* policies (marketing site, app, help center) but at most one
|
|
24
|
+
* is active at a time. `setActivePolicy(slug)` flips the
|
|
25
|
+
* active one in place; the partial-unique index in migration
|
|
26
|
+
* 0139 enforces the at-most-one invariant at the SQL tier.
|
|
27
|
+
*
|
|
28
|
+
* `resolveLocale({ request })` walks the request hints in a fixed
|
|
29
|
+
* precedence (which subset applies depends on the active policy's
|
|
30
|
+
* strategy):
|
|
31
|
+
*
|
|
32
|
+
* 1. `customer_id` -> customer_locale_prefs (when strategy ==
|
|
33
|
+
* `customer_preference`, OR for every strategy when the row
|
|
34
|
+
* exists — operators expect a logged-in buyer's explicit pick
|
|
35
|
+
* to survive across surface changes);
|
|
36
|
+
* 2. URL prefix (`/en/...`, `/de/...`, `/fr-ca/...`) — strategy
|
|
37
|
+
* `url_prefix`;
|
|
38
|
+
* 3. Subdomain (`de.example.com`) — strategy `subdomain`;
|
|
39
|
+
* 4. Cookie (`cookie_locale`) — strategy `cookie`;
|
|
40
|
+
* 5. Accept-Language q-sorted list — every strategy; the last
|
|
41
|
+
* hint before the default.
|
|
42
|
+
* 6. The policy's `default_locale`.
|
|
43
|
+
*
|
|
44
|
+
* Each step returns its hit only when the candidate tag is in the
|
|
45
|
+
* policy's `supported_locales` list OR when the tag's `fallback`
|
|
46
|
+
* chain (defined on the locale catalog) resolves to one that is.
|
|
47
|
+
* A request whose final resolution is the default locale records
|
|
48
|
+
* `source: "default"` even when an upstream hint was present —
|
|
49
|
+
* operators reconciling "why did this buyer see English?" should
|
|
50
|
+
* see the truthful "we walked all hints and none matched."
|
|
51
|
+
*
|
|
52
|
+
* The return shape is
|
|
53
|
+
*
|
|
54
|
+
* { locale: string, source: string, canonical_url?: string }
|
|
55
|
+
*
|
|
56
|
+
* The `canonical_url` is emitted only for `url_prefix` and
|
|
57
|
+
* `subdomain` strategies — it's the canonical form of the
|
|
58
|
+
* request URL with the resolved locale baked in (operators stamp
|
|
59
|
+
* it into a `<link rel="canonical">` to coalesce duplicate
|
|
60
|
+
* indexing on the SEO side).
|
|
61
|
+
*
|
|
62
|
+
* Composition:
|
|
63
|
+
*
|
|
64
|
+
* var lr = localeRouter.create({
|
|
65
|
+
* query: q,
|
|
66
|
+
* customers: bShop.customers, // optional — never required
|
|
67
|
+
* geolocation: bShop.geolocation,// optional, accepted for
|
|
68
|
+
* // composition symmetry; the
|
|
69
|
+
* // primitive resolves country
|
|
70
|
+
* // hints only as Accept-Language
|
|
71
|
+
* // fallbacks.
|
|
72
|
+
* });
|
|
73
|
+
* await lr.defineLocale({ tag: "en", kind: "primary", currency: "USD", active: true });
|
|
74
|
+
* await lr.defineLocale({ tag: "en-US", kind: "regional", fallback: "en", currency: "USD", active: true });
|
|
75
|
+
* await lr.defineLocale({ tag: "de", kind: "primary", currency: "EUR", active: true });
|
|
76
|
+
* await lr.definePolicy({
|
|
77
|
+
* slug: "main",
|
|
78
|
+
* strategy: "url_prefix",
|
|
79
|
+
* default_locale: "en",
|
|
80
|
+
* supported_locales: ["en", "en-US", "de"],
|
|
81
|
+
* });
|
|
82
|
+
* await lr.setActivePolicy("main");
|
|
83
|
+
* var out = await lr.resolveLocale({
|
|
84
|
+
* request: {
|
|
85
|
+
* host: "example.com",
|
|
86
|
+
* path: "/de/about",
|
|
87
|
+
* accept_language: "de-DE,de;q=0.9,en;q=0.5",
|
|
88
|
+
* },
|
|
89
|
+
* });
|
|
90
|
+
* // { locale: "de", source: "url_prefix",
|
|
91
|
+
* // canonical_url: "https://example.com/de/about" }
|
|
92
|
+
*
|
|
93
|
+
* Storage:
|
|
94
|
+
* - `locales_defined` + `locale_policies` +
|
|
95
|
+
* `customer_locale_prefs` + `locale_resolutions_log`
|
|
96
|
+
* (migration `0139_locale_router.sql`).
|
|
97
|
+
*
|
|
98
|
+
* @primitive localeRouter
|
|
99
|
+
* @related shop.geolocation, shop.priceDisplay, shop.currencyDisplay
|
|
100
|
+
*/
|
|
101
|
+
|
|
102
|
+
var bShop;
|
|
103
|
+
function _b() {
|
|
104
|
+
if (!bShop) bShop = require("./index");
|
|
105
|
+
return bShop.framework;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---- constants ----------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
var KINDS = ["primary", "regional", "variant"];
|
|
111
|
+
var STRATEGIES = [
|
|
112
|
+
"url_prefix",
|
|
113
|
+
"subdomain",
|
|
114
|
+
"cookie",
|
|
115
|
+
"accept_language_only",
|
|
116
|
+
"customer_preference",
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
// BCP-47 — primary language subtag (2-3 letters) plus optional
|
|
120
|
+
// region / script / variant subtags joined by `-`. Practical
|
|
121
|
+
// storefront locales fit the `lang(-Region)?` envelope; the schema
|
|
122
|
+
// CHECK bounds total length at 35 (IETF practical max).
|
|
123
|
+
var LOCALE_RE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{1,8})*$/;
|
|
124
|
+
var LOCALE_MAX = 35;
|
|
125
|
+
// ISO 4217 — three uppercase letters.
|
|
126
|
+
var CURRENCY_RE = /^[A-Z]{3}$/;
|
|
127
|
+
// Slug for policy primary key — lowercase alphanumerics + dash +
|
|
128
|
+
// underscore, 1-80 chars.
|
|
129
|
+
var SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,79}$/;
|
|
130
|
+
// customer_id — opaque, but bounded so we don't store a megabyte by
|
|
131
|
+
// accident.
|
|
132
|
+
var CUSTOMER_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
|
|
133
|
+
// Host — RFC 1123 subset; we accept lowercase / digit / dot / dash,
|
|
134
|
+
// 1-253 chars. Port is rejected (the caller strips it before
|
|
135
|
+
// passing in).
|
|
136
|
+
var HOST_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/;
|
|
137
|
+
var HOST_MAX = 253;
|
|
138
|
+
// Path — `/`-prefixed, no control bytes, bounded length so we don't
|
|
139
|
+
// log a megabyte from a malicious caller.
|
|
140
|
+
var PATH_MAX = 2048;
|
|
141
|
+
// Accept-Language and cookie values are bounded so a buggy upstream
|
|
142
|
+
// can't OOM us through the log.
|
|
143
|
+
var HEADER_MAX = 4096;
|
|
144
|
+
var SUPPORTED_MIN = 1;
|
|
145
|
+
var SUPPORTED_MAX = 128;
|
|
146
|
+
var MAX_LIST_LIMIT = 500;
|
|
147
|
+
// Fallback walking — cap the chain depth so a manually-poked
|
|
148
|
+
// `fallback = self` loop bounces off at a deterministic point.
|
|
149
|
+
var FALLBACK_MAX_DEPTH = 16;
|
|
150
|
+
|
|
151
|
+
// ---- validators ---------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
function _locale(s, label) {
|
|
154
|
+
if (typeof s !== "string" || !LOCALE_RE.test(s) || s.length > LOCALE_MAX) {
|
|
155
|
+
throw new TypeError(
|
|
156
|
+
"localeRouter: " + label + " must be a BCP-47 language tag " +
|
|
157
|
+
"(e.g. 'en', 'de-DE'), got " + JSON.stringify(s)
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return s;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function _kind(s) {
|
|
164
|
+
if (typeof s !== "string" || KINDS.indexOf(s) === -1) {
|
|
165
|
+
throw new TypeError(
|
|
166
|
+
"localeRouter: kind must be one of " + KINDS.join(", ") +
|
|
167
|
+
", got " + JSON.stringify(s)
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return s;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function _strategy(s) {
|
|
174
|
+
if (typeof s !== "string" || STRATEGIES.indexOf(s) === -1) {
|
|
175
|
+
throw new TypeError(
|
|
176
|
+
"localeRouter: strategy must be one of " + STRATEGIES.join(", ") +
|
|
177
|
+
", got " + JSON.stringify(s)
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
return s;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function _currency(s) {
|
|
184
|
+
if (typeof s !== "string" || !CURRENCY_RE.test(s)) {
|
|
185
|
+
throw new TypeError(
|
|
186
|
+
"localeRouter: currency must be a 3-letter uppercase ISO 4217 code, got " +
|
|
187
|
+
JSON.stringify(s)
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
return s;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function _slug(s) {
|
|
194
|
+
if (typeof s !== "string" || !SLUG_RE.test(s)) {
|
|
195
|
+
throw new TypeError(
|
|
196
|
+
"localeRouter: slug must match /^[a-z0-9][a-z0-9_-]{0,79}$/, got " +
|
|
197
|
+
JSON.stringify(s)
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
return s;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function _customerId(s, label) {
|
|
204
|
+
label = label || "customer_id";
|
|
205
|
+
if (typeof s !== "string" || !CUSTOMER_ID_RE.test(s)) {
|
|
206
|
+
throw new TypeError(
|
|
207
|
+
"localeRouter: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/, got " +
|
|
208
|
+
JSON.stringify(s)
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return s;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function _bool(v, label) {
|
|
215
|
+
if (typeof v !== "boolean") {
|
|
216
|
+
throw new TypeError("localeRouter: " + label + " must be a boolean");
|
|
217
|
+
}
|
|
218
|
+
return v;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function _supportedList(arr, label) {
|
|
222
|
+
if (!Array.isArray(arr)) {
|
|
223
|
+
throw new TypeError("localeRouter: " + label + " must be an array of locale tags");
|
|
224
|
+
}
|
|
225
|
+
if (arr.length < SUPPORTED_MIN || arr.length > SUPPORTED_MAX) {
|
|
226
|
+
throw new TypeError(
|
|
227
|
+
"localeRouter: " + label + " must contain between " + SUPPORTED_MIN +
|
|
228
|
+
" and " + SUPPORTED_MAX + " entries, got " + arr.length
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
var seen = Object.create(null);
|
|
232
|
+
var out = [];
|
|
233
|
+
for (var i = 0; i < arr.length; i += 1) {
|
|
234
|
+
var t = _locale(arr[i], label + "[" + i + "]");
|
|
235
|
+
if (seen[t]) {
|
|
236
|
+
throw new TypeError(
|
|
237
|
+
"localeRouter: " + label + " must not contain duplicate tag " +
|
|
238
|
+
JSON.stringify(t)
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
seen[t] = true;
|
|
242
|
+
out.push(t);
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function _host(s, label) {
|
|
248
|
+
label = label || "host";
|
|
249
|
+
if (typeof s !== "string" || !s.length || s.length > HOST_MAX) {
|
|
250
|
+
throw new TypeError(
|
|
251
|
+
"localeRouter: " + label + " must be a non-empty string <= " +
|
|
252
|
+
HOST_MAX + " chars"
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
// Accept `Host:` style hostnames in either case but normalise to
|
|
256
|
+
// lowercase before matching; subdomain detection is case-insensitive.
|
|
257
|
+
var lower = s.toLowerCase();
|
|
258
|
+
// Strip an `:port` suffix defensively — operators sometimes feed in
|
|
259
|
+
// the raw `Host` header, which on a non-standard port carries the
|
|
260
|
+
// suffix.
|
|
261
|
+
var colon = lower.indexOf(":");
|
|
262
|
+
if (colon !== -1) lower = lower.slice(0, colon);
|
|
263
|
+
if (!HOST_RE.test(lower)) {
|
|
264
|
+
throw new TypeError(
|
|
265
|
+
"localeRouter: " + label + " must be a valid hostname, got " +
|
|
266
|
+
JSON.stringify(s)
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
return lower;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function _path(s, label) {
|
|
273
|
+
label = label || "path";
|
|
274
|
+
if (typeof s !== "string" || s.length === 0 || s.length > PATH_MAX) {
|
|
275
|
+
throw new TypeError(
|
|
276
|
+
"localeRouter: " + label + " must be a non-empty string <= " +
|
|
277
|
+
PATH_MAX + " chars"
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
if (s.charAt(0) !== "/") {
|
|
281
|
+
throw new TypeError(
|
|
282
|
+
"localeRouter: " + label + " must start with '/', got " +
|
|
283
|
+
JSON.stringify(s)
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
if (/[\x00-\x1F\x7F]/.test(s)) {
|
|
287
|
+
throw new TypeError("localeRouter: " + label + " must not contain control bytes");
|
|
288
|
+
}
|
|
289
|
+
return s;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function _headerOpt(s, label) {
|
|
293
|
+
if (s == null) return null;
|
|
294
|
+
if (typeof s !== "string") {
|
|
295
|
+
throw new TypeError("localeRouter: " + label + " must be a string or null");
|
|
296
|
+
}
|
|
297
|
+
if (s.length > HEADER_MAX) {
|
|
298
|
+
throw new TypeError(
|
|
299
|
+
"localeRouter: " + label + " must be <= " + HEADER_MAX + " chars"
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
if (/[\x00-\x1F\x7F]/.test(s)) {
|
|
303
|
+
throw new TypeError("localeRouter: " + label + " must not contain control bytes");
|
|
304
|
+
}
|
|
305
|
+
return s;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function _limit(n, label) {
|
|
309
|
+
if (n == null) return 100;
|
|
310
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > MAX_LIST_LIMIT) {
|
|
311
|
+
throw new TypeError(
|
|
312
|
+
"localeRouter: " + label + " must be an integer in [1, " + MAX_LIST_LIMIT + "]"
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
return n;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function _epochMs(ts, label) {
|
|
319
|
+
if (ts == null) return null;
|
|
320
|
+
if (typeof ts !== "number" || !Number.isInteger(ts) || ts < 0) {
|
|
321
|
+
throw new TypeError(
|
|
322
|
+
"localeRouter: " + label + " must be a non-negative integer epoch-ms, got " +
|
|
323
|
+
JSON.stringify(ts)
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
return ts;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---- accept-language parsing -------------------------------------------
|
|
330
|
+
|
|
331
|
+
// Same shape as the geolocation primitive — parse the RFC 7231
|
|
332
|
+
// header into a q-sorted list of `{ tag, q, order }` entries.
|
|
333
|
+
// Garbage entries silently drop; the worst case is an entirely
|
|
334
|
+
// malformed header that falls through to the next resolution step.
|
|
335
|
+
function _parseAcceptLanguage(raw) {
|
|
336
|
+
if (typeof raw !== "string" || !raw.length) return [];
|
|
337
|
+
var entries = raw.split(",");
|
|
338
|
+
var parsed = [];
|
|
339
|
+
for (var i = 0; i < entries.length; i += 1) {
|
|
340
|
+
var part = entries[i].trim();
|
|
341
|
+
if (!part) continue;
|
|
342
|
+
var semi = part.indexOf(";");
|
|
343
|
+
var tag = (semi === -1 ? part : part.slice(0, semi)).trim();
|
|
344
|
+
var q = 1.0;
|
|
345
|
+
if (semi !== -1) {
|
|
346
|
+
var attrs = part.slice(semi + 1).split(";");
|
|
347
|
+
for (var j = 0; j < attrs.length; j += 1) {
|
|
348
|
+
var a = attrs[j].trim();
|
|
349
|
+
if (a.slice(0, 2).toLowerCase() === "q=") {
|
|
350
|
+
var n = parseFloat(a.slice(2));
|
|
351
|
+
if (isFinite(n) && n >= 0 && n <= 1) q = n;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (tag === "*") continue;
|
|
356
|
+
if (!LOCALE_RE.test(tag) || tag.length > LOCALE_MAX) continue;
|
|
357
|
+
parsed.push({ tag: tag, q: q, order: i });
|
|
358
|
+
}
|
|
359
|
+
parsed.sort(function (a, b) {
|
|
360
|
+
if (a.q !== b.q) return b.q - a.q;
|
|
361
|
+
return a.order - b.order;
|
|
362
|
+
});
|
|
363
|
+
return parsed;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---- url + subdomain extraction ----------------------------------------
|
|
367
|
+
|
|
368
|
+
// Pull the first path segment when it is shaped like a BCP-47 tag.
|
|
369
|
+
// Returns the lowercased tag (matching the storage convention used
|
|
370
|
+
// throughout — `defineLocale` preserves the operator's casing on the
|
|
371
|
+
// catalog row, but resolution comparisons fold both sides to lower
|
|
372
|
+
// case) or null when the path has no leading locale segment.
|
|
373
|
+
function _urlPrefixCandidate(path) {
|
|
374
|
+
var rest = path.slice(1); // drop leading "/"
|
|
375
|
+
if (!rest.length) return null;
|
|
376
|
+
var slash = rest.indexOf("/");
|
|
377
|
+
var seg = slash === -1 ? rest : rest.slice(0, slash);
|
|
378
|
+
if (!seg.length || seg.length > LOCALE_MAX) return null;
|
|
379
|
+
if (!LOCALE_RE.test(seg)) return null;
|
|
380
|
+
return seg;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Pull the leftmost subdomain when it is shaped like a BCP-47 tag.
|
|
384
|
+
// `de.example.com` -> `de`. `www.example.com` -> null (the regex
|
|
385
|
+
// rejects 4+ letter primary tags on the assumption that no language
|
|
386
|
+
// tag is longer than 3 letters in its primary subtag — keeps `www`
|
|
387
|
+
// / `app` / `api` from being mistaken for locales).
|
|
388
|
+
function _subdomainCandidate(host) {
|
|
389
|
+
if (host.indexOf(".") === -1) return null;
|
|
390
|
+
var first = host.slice(0, host.indexOf("."));
|
|
391
|
+
if (!first.length || first.length > LOCALE_MAX) return null;
|
|
392
|
+
if (!LOCALE_RE.test(first)) return null;
|
|
393
|
+
return first;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ---- row shaping -------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
function _shapeLocale(row) {
|
|
399
|
+
if (!row) return null;
|
|
400
|
+
return {
|
|
401
|
+
tag: row.tag,
|
|
402
|
+
kind: row.kind,
|
|
403
|
+
fallback: row.fallback == null ? null : row.fallback,
|
|
404
|
+
currency: row.currency,
|
|
405
|
+
active: Number(row.active) === 1,
|
|
406
|
+
archived_at: row.archived_at == null ? null : Number(row.archived_at),
|
|
407
|
+
created_at: Number(row.created_at),
|
|
408
|
+
updated_at: Number(row.updated_at),
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function _shapePolicy(row) {
|
|
413
|
+
if (!row) return null;
|
|
414
|
+
var supported;
|
|
415
|
+
try { supported = JSON.parse(row.supported_locales_json); }
|
|
416
|
+
catch (_e) {
|
|
417
|
+
throw new Error(
|
|
418
|
+
"localeRouter: supported_locales_json column is malformed JSON — storage corruption"
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
if (!Array.isArray(supported)) {
|
|
422
|
+
throw new Error(
|
|
423
|
+
"localeRouter: supported_locales_json must be a JSON array — storage corruption"
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
return {
|
|
427
|
+
slug: row.slug,
|
|
428
|
+
strategy: row.strategy,
|
|
429
|
+
default_locale: row.default_locale,
|
|
430
|
+
supported_locales: supported,
|
|
431
|
+
is_active: Number(row.is_active) === 1,
|
|
432
|
+
archived_at: row.archived_at == null ? null : Number(row.archived_at),
|
|
433
|
+
created_at: Number(row.created_at),
|
|
434
|
+
updated_at: Number(row.updated_at),
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function _shapeCustomerPref(row) {
|
|
439
|
+
if (!row) return null;
|
|
440
|
+
return {
|
|
441
|
+
customer_id: row.customer_id,
|
|
442
|
+
locale: row.locale,
|
|
443
|
+
set_at: Number(row.set_at),
|
|
444
|
+
updated_at: Number(row.updated_at),
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ---- factory -----------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
function create(opts) {
|
|
451
|
+
opts = opts || {};
|
|
452
|
+
var query = opts.query;
|
|
453
|
+
if (!query) {
|
|
454
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
455
|
+
}
|
|
456
|
+
if (typeof query !== "function") {
|
|
457
|
+
throw new TypeError("localeRouter.create: query must be a function");
|
|
458
|
+
}
|
|
459
|
+
// The `customers` and `geolocation` deps are accepted for
|
|
460
|
+
// composition symmetry — the primitive validates customer ids by
|
|
461
|
+
// shape (opaque string) and never round-trips through the customers
|
|
462
|
+
// primitive, and it consumes geolocation hints only via the
|
|
463
|
+
// request shape's existing fields. Both args are recorded so a
|
|
464
|
+
// future operator-level hook can wire them without breaking this
|
|
465
|
+
// signature.
|
|
466
|
+
/* eslint-disable no-unused-vars */
|
|
467
|
+
var customers = opts.customers || null;
|
|
468
|
+
var geolocation = opts.geolocation || null;
|
|
469
|
+
/* eslint-enable no-unused-vars */
|
|
470
|
+
|
|
471
|
+
// Monotonic clock — per-row class. The primitive guarantees a
|
|
472
|
+
// strictly-monotonic per-class updated_at sequence so a tied-
|
|
473
|
+
// millisecond write doesn't lose ordering against the prior write.
|
|
474
|
+
// Same shape as the currency-rounding / refund-policy clamps.
|
|
475
|
+
var lastTsLocale = Object.create(null);
|
|
476
|
+
var lastTsPolicy = Object.create(null);
|
|
477
|
+
var lastTsCustomer = Object.create(null);
|
|
478
|
+
// Resolutions log carries the wall-clock — monotonic per stream
|
|
479
|
+
// so a busy second still orders correctly.
|
|
480
|
+
var lastTsLog = 0;
|
|
481
|
+
|
|
482
|
+
function _now() { return Date.now(); }
|
|
483
|
+
function _clampLocale(tag, requested) {
|
|
484
|
+
var prior = lastTsLocale[tag];
|
|
485
|
+
var t = requested;
|
|
486
|
+
if (prior != null && t <= prior) t = prior + 1;
|
|
487
|
+
lastTsLocale[tag] = t;
|
|
488
|
+
return t;
|
|
489
|
+
}
|
|
490
|
+
function _clampPolicy(slug, requested) {
|
|
491
|
+
var prior = lastTsPolicy[slug];
|
|
492
|
+
var t = requested;
|
|
493
|
+
if (prior != null && t <= prior) t = prior + 1;
|
|
494
|
+
lastTsPolicy[slug] = t;
|
|
495
|
+
return t;
|
|
496
|
+
}
|
|
497
|
+
function _clampCustomer(cid, requested) {
|
|
498
|
+
var prior = lastTsCustomer[cid];
|
|
499
|
+
var t = requested;
|
|
500
|
+
if (prior != null && t <= prior) t = prior + 1;
|
|
501
|
+
lastTsCustomer[cid] = t;
|
|
502
|
+
return t;
|
|
503
|
+
}
|
|
504
|
+
function _clampLog(requested) {
|
|
505
|
+
var t = requested;
|
|
506
|
+
if (t <= lastTsLog) t = lastTsLog + 1;
|
|
507
|
+
lastTsLog = t;
|
|
508
|
+
return t;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ---- locale catalog ------------------------------------------------
|
|
512
|
+
|
|
513
|
+
async function _readLocale(tag) {
|
|
514
|
+
var r = await query(
|
|
515
|
+
"SELECT tag, kind, fallback, currency, active, archived_at, " +
|
|
516
|
+
"created_at, updated_at FROM locales_defined WHERE tag = ?1 LIMIT 1",
|
|
517
|
+
[tag]
|
|
518
|
+
);
|
|
519
|
+
return r.rows[0] || null;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function defineLocale(input) {
|
|
523
|
+
if (!input || typeof input !== "object") {
|
|
524
|
+
throw new TypeError("localeRouter.defineLocale: input object required");
|
|
525
|
+
}
|
|
526
|
+
var tag = _locale(input.tag, "tag");
|
|
527
|
+
var kind = _kind(input.kind);
|
|
528
|
+
var currency = _currency(input.currency);
|
|
529
|
+
var active = _bool(input.active, "active");
|
|
530
|
+
var fallback = input.fallback == null ? null : _locale(input.fallback, "fallback");
|
|
531
|
+
|
|
532
|
+
if (fallback != null) {
|
|
533
|
+
if (fallback === tag) {
|
|
534
|
+
throw new TypeError(
|
|
535
|
+
"localeRouter.defineLocale: fallback cannot equal tag (would loop)"
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
// Fallback must reference an existing locale row. The schema
|
|
539
|
+
// doesn't FK this (D1 best-effort), so we enforce it here.
|
|
540
|
+
var fbRow = await _readLocale(fallback);
|
|
541
|
+
if (!fbRow) {
|
|
542
|
+
throw new TypeError(
|
|
543
|
+
"localeRouter.defineLocale: fallback " + JSON.stringify(fallback) +
|
|
544
|
+
" does not reference a known locale"
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
var existing = await _readLocale(tag);
|
|
550
|
+
var ts = _clampLocale(tag, _now());
|
|
551
|
+
if (existing) {
|
|
552
|
+
// Re-define-after-archive (or in-place refresh) — re-set every
|
|
553
|
+
// operator-facing column. `archived_at` clears unless `active`
|
|
554
|
+
// is false.
|
|
555
|
+
await query(
|
|
556
|
+
"UPDATE locales_defined SET kind = ?1, fallback = ?2, currency = ?3, " +
|
|
557
|
+
"active = ?4, archived_at = ?5, updated_at = ?6 WHERE tag = ?7",
|
|
558
|
+
[
|
|
559
|
+
kind,
|
|
560
|
+
fallback,
|
|
561
|
+
currency,
|
|
562
|
+
active ? 1 : 0,
|
|
563
|
+
active ? null : ts,
|
|
564
|
+
ts,
|
|
565
|
+
tag,
|
|
566
|
+
]
|
|
567
|
+
);
|
|
568
|
+
} else {
|
|
569
|
+
await query(
|
|
570
|
+
"INSERT INTO locales_defined " +
|
|
571
|
+
"(tag, kind, fallback, currency, active, archived_at, created_at, updated_at) " +
|
|
572
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)",
|
|
573
|
+
[
|
|
574
|
+
tag, kind, fallback, currency,
|
|
575
|
+
active ? 1 : 0,
|
|
576
|
+
active ? null : ts,
|
|
577
|
+
ts,
|
|
578
|
+
]
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
return _shapeLocale(await _readLocale(tag));
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function getLocale(tag) {
|
|
585
|
+
return _shapeLocale(await _readLocale(_locale(tag, "tag")));
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async function listLocales(input) {
|
|
589
|
+
input = input || {};
|
|
590
|
+
var activeOnly = input.active_only == null ? false : input.active_only;
|
|
591
|
+
if (typeof activeOnly !== "boolean") {
|
|
592
|
+
throw new TypeError("localeRouter.listLocales: active_only must be a boolean");
|
|
593
|
+
}
|
|
594
|
+
var limit = _limit(input.limit, "limit");
|
|
595
|
+
var sql = "SELECT tag, kind, fallback, currency, active, archived_at, " +
|
|
596
|
+
"created_at, updated_at FROM locales_defined";
|
|
597
|
+
var params = [];
|
|
598
|
+
if (activeOnly) sql += " WHERE active = 1";
|
|
599
|
+
sql += " ORDER BY tag ASC LIMIT ?" + (params.length + 1);
|
|
600
|
+
params.push(limit);
|
|
601
|
+
var r = await query(sql, params);
|
|
602
|
+
var out = [];
|
|
603
|
+
for (var i = 0; i < r.rows.length; i += 1) out.push(_shapeLocale(r.rows[i]));
|
|
604
|
+
return out;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ---- policy catalog ------------------------------------------------
|
|
608
|
+
|
|
609
|
+
async function _readPolicy(slug) {
|
|
610
|
+
var r = await query(
|
|
611
|
+
"SELECT slug, strategy, default_locale, supported_locales_json, " +
|
|
612
|
+
"is_active, archived_at, created_at, updated_at " +
|
|
613
|
+
"FROM locale_policies WHERE slug = ?1 LIMIT 1",
|
|
614
|
+
[slug]
|
|
615
|
+
);
|
|
616
|
+
return r.rows[0] || null;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async function definePolicy(input) {
|
|
620
|
+
if (!input || typeof input !== "object") {
|
|
621
|
+
throw new TypeError("localeRouter.definePolicy: input object required");
|
|
622
|
+
}
|
|
623
|
+
var slug = _slug(input.slug);
|
|
624
|
+
var strategy = _strategy(input.strategy);
|
|
625
|
+
var defaultLocale = _locale(input.default_locale, "default_locale");
|
|
626
|
+
var supportedLocales = _supportedList(input.supported_locales, "supported_locales");
|
|
627
|
+
|
|
628
|
+
if (supportedLocales.indexOf(defaultLocale) === -1) {
|
|
629
|
+
throw new TypeError(
|
|
630
|
+
"localeRouter.definePolicy: default_locale " + JSON.stringify(defaultLocale) +
|
|
631
|
+
" must appear in supported_locales"
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
// Every entry in supported_locales must reference an existing
|
|
635
|
+
// locale row. Walk the list in order so the error message points
|
|
636
|
+
// at the first offender deterministically.
|
|
637
|
+
for (var i = 0; i < supportedLocales.length; i += 1) {
|
|
638
|
+
var row = await _readLocale(supportedLocales[i]);
|
|
639
|
+
if (!row) {
|
|
640
|
+
throw new TypeError(
|
|
641
|
+
"localeRouter.definePolicy: supported_locales[" + i + "] " +
|
|
642
|
+
JSON.stringify(supportedLocales[i]) + " does not reference a known locale"
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
var existing = await _readPolicy(slug);
|
|
648
|
+
var ts = _clampPolicy(slug, _now());
|
|
649
|
+
var supportedJson = JSON.stringify(supportedLocales);
|
|
650
|
+
if (existing) {
|
|
651
|
+
// Re-define refreshes in place — but never silently flips
|
|
652
|
+
// is_active. Operators flip the active policy via
|
|
653
|
+
// setActivePolicy.
|
|
654
|
+
await query(
|
|
655
|
+
"UPDATE locale_policies SET strategy = ?1, default_locale = ?2, " +
|
|
656
|
+
"supported_locales_json = ?3, archived_at = NULL, updated_at = ?4 " +
|
|
657
|
+
"WHERE slug = ?5",
|
|
658
|
+
[strategy, defaultLocale, supportedJson, ts, slug]
|
|
659
|
+
);
|
|
660
|
+
} else {
|
|
661
|
+
await query(
|
|
662
|
+
"INSERT INTO locale_policies " +
|
|
663
|
+
"(slug, strategy, default_locale, supported_locales_json, " +
|
|
664
|
+
" is_active, archived_at, created_at, updated_at) " +
|
|
665
|
+
"VALUES (?1, ?2, ?3, ?4, 0, NULL, ?5, ?5)",
|
|
666
|
+
[slug, strategy, defaultLocale, supportedJson, ts]
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
return _shapePolicy(await _readPolicy(slug));
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async function getPolicy(slug) {
|
|
673
|
+
return _shapePolicy(await _readPolicy(_slug(slug)));
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
async function listPolicies(input) {
|
|
677
|
+
input = input || {};
|
|
678
|
+
var includeArchived = input.include_archived == null ? false : input.include_archived;
|
|
679
|
+
if (typeof includeArchived !== "boolean") {
|
|
680
|
+
throw new TypeError("localeRouter.listPolicies: include_archived must be a boolean");
|
|
681
|
+
}
|
|
682
|
+
var limit = _limit(input.limit, "limit");
|
|
683
|
+
var sql = "SELECT slug, strategy, default_locale, supported_locales_json, " +
|
|
684
|
+
"is_active, archived_at, created_at, updated_at FROM locale_policies";
|
|
685
|
+
var params = [];
|
|
686
|
+
if (!includeArchived) sql += " WHERE archived_at IS NULL";
|
|
687
|
+
sql += " ORDER BY slug ASC LIMIT ?" + (params.length + 1);
|
|
688
|
+
params.push(limit);
|
|
689
|
+
var r = await query(sql, params);
|
|
690
|
+
var out = [];
|
|
691
|
+
for (var i = 0; i < r.rows.length; i += 1) out.push(_shapePolicy(r.rows[i]));
|
|
692
|
+
return out;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async function archivePolicy(slug) {
|
|
696
|
+
var s = _slug(slug);
|
|
697
|
+
var existing = await _readPolicy(s);
|
|
698
|
+
if (!existing) {
|
|
699
|
+
throw new TypeError(
|
|
700
|
+
"localeRouter.archivePolicy: no policy exists for slug " + JSON.stringify(s)
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
if (existing.archived_at != null) {
|
|
704
|
+
// Idempotent: an already-archived policy re-archives as a no-op.
|
|
705
|
+
return _shapePolicy(existing);
|
|
706
|
+
}
|
|
707
|
+
var ts = _clampPolicy(s, _now());
|
|
708
|
+
// Archiving an active policy also clears is_active so the
|
|
709
|
+
// partial-unique invariant holds.
|
|
710
|
+
await query(
|
|
711
|
+
"UPDATE locale_policies SET archived_at = ?1, is_active = 0, " +
|
|
712
|
+
"updated_at = ?2 WHERE slug = ?3",
|
|
713
|
+
[ts, ts, s]
|
|
714
|
+
);
|
|
715
|
+
return _shapePolicy(await _readPolicy(s));
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// ---- active policy -------------------------------------------------
|
|
719
|
+
|
|
720
|
+
async function setActivePolicy(slug) {
|
|
721
|
+
var s = _slug(slug);
|
|
722
|
+
var existing = await _readPolicy(s);
|
|
723
|
+
if (!existing) {
|
|
724
|
+
throw new TypeError(
|
|
725
|
+
"localeRouter.setActivePolicy: no policy exists for slug " + JSON.stringify(s)
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
if (existing.archived_at != null) {
|
|
729
|
+
throw new TypeError(
|
|
730
|
+
"localeRouter.setActivePolicy: cannot activate archived policy " +
|
|
731
|
+
JSON.stringify(s) + " — re-define it first"
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
var ts = _clampPolicy(s, _now());
|
|
735
|
+
// Demote every other active policy first so the partial-unique
|
|
736
|
+
// index never blocks the activation INSERT/UPDATE. Done in two
|
|
737
|
+
// statements (D1 doesn't run transactional multi-statement
|
|
738
|
+
// batches in the workflow we ship under, but the worst case is
|
|
739
|
+
// an ephemeral "no active policy" window followed by the second
|
|
740
|
+
// update — resolveLocale handles "no active policy" cleanly).
|
|
741
|
+
await query(
|
|
742
|
+
"UPDATE locale_policies SET is_active = 0, updated_at = ?1 " +
|
|
743
|
+
"WHERE is_active = 1 AND slug != ?2",
|
|
744
|
+
[ts, s]
|
|
745
|
+
);
|
|
746
|
+
await query(
|
|
747
|
+
"UPDATE locale_policies SET is_active = 1, updated_at = ?1 " +
|
|
748
|
+
"WHERE slug = ?2",
|
|
749
|
+
[ts, s]
|
|
750
|
+
);
|
|
751
|
+
return _shapePolicy(await _readPolicy(s));
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async function activePolicy() {
|
|
755
|
+
var r = await query(
|
|
756
|
+
"SELECT slug, strategy, default_locale, supported_locales_json, " +
|
|
757
|
+
"is_active, archived_at, created_at, updated_at " +
|
|
758
|
+
"FROM locale_policies WHERE is_active = 1 LIMIT 1",
|
|
759
|
+
[]
|
|
760
|
+
);
|
|
761
|
+
return _shapePolicy(r.rows[0] || null);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// ---- customer prefs ------------------------------------------------
|
|
765
|
+
|
|
766
|
+
async function _readCustomerPref(customerId) {
|
|
767
|
+
var r = await query(
|
|
768
|
+
"SELECT customer_id, locale, set_at, updated_at " +
|
|
769
|
+
"FROM customer_locale_prefs WHERE customer_id = ?1 LIMIT 1",
|
|
770
|
+
[customerId]
|
|
771
|
+
);
|
|
772
|
+
return r.rows[0] || null;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
async function setCustomerLocale(input) {
|
|
776
|
+
if (!input || typeof input !== "object") {
|
|
777
|
+
throw new TypeError("localeRouter.setCustomerLocale: input object required");
|
|
778
|
+
}
|
|
779
|
+
var customerId = _customerId(input.customer_id, "customer_id");
|
|
780
|
+
var loc = _locale(input.locale, "locale");
|
|
781
|
+
// The locale must reference an existing (not necessarily active)
|
|
782
|
+
// catalog row. An archived locale is allowed — operators flipping
|
|
783
|
+
// a buyer's pref to a discontinued locale (audit / historical
|
|
784
|
+
// replay) shouldn't be blocked.
|
|
785
|
+
var row = await _readLocale(loc);
|
|
786
|
+
if (!row) {
|
|
787
|
+
throw new TypeError(
|
|
788
|
+
"localeRouter.setCustomerLocale: locale " + JSON.stringify(loc) +
|
|
789
|
+
" does not reference a known locale"
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
var existing = await _readCustomerPref(customerId);
|
|
793
|
+
var ts = _clampCustomer(customerId, _now());
|
|
794
|
+
if (existing) {
|
|
795
|
+
await query(
|
|
796
|
+
"UPDATE customer_locale_prefs SET locale = ?1, updated_at = ?2 " +
|
|
797
|
+
"WHERE customer_id = ?3",
|
|
798
|
+
[loc, ts, customerId]
|
|
799
|
+
);
|
|
800
|
+
} else {
|
|
801
|
+
await query(
|
|
802
|
+
"INSERT INTO customer_locale_prefs " +
|
|
803
|
+
"(customer_id, locale, set_at, updated_at) VALUES (?1, ?2, ?3, ?3)",
|
|
804
|
+
[customerId, loc, ts]
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
return _shapeCustomerPref(await _readCustomerPref(customerId));
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
async function clearCustomerLocale(customerId) {
|
|
811
|
+
var cid = _customerId(customerId, "customer_id");
|
|
812
|
+
var r = await query(
|
|
813
|
+
"DELETE FROM customer_locale_prefs WHERE customer_id = ?1",
|
|
814
|
+
[cid]
|
|
815
|
+
);
|
|
816
|
+
return { cleared: Number(r.rowCount || 0) > 0, customer_id: cid };
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
async function getCustomerLocale(customerId) {
|
|
820
|
+
var cid = _customerId(customerId, "customer_id");
|
|
821
|
+
return _shapeCustomerPref(await _readCustomerPref(cid));
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// ---- resolution ----------------------------------------------------
|
|
825
|
+
|
|
826
|
+
// Walk a candidate tag against the policy's supported list. The
|
|
827
|
+
// candidate matches when:
|
|
828
|
+
// 1. it appears in supported_locales (case-insensitive — BCP-47
|
|
829
|
+
// tags are case-insensitive on the wire);
|
|
830
|
+
// 2. or its primary subtag matches a supported tag's primary
|
|
831
|
+
// subtag (operators who declare only `en` answer requests for
|
|
832
|
+
// `en-GB` with `en`);
|
|
833
|
+
// 3. or the fallback chain on the catalog row leads to one that
|
|
834
|
+
// matches (1) or (2).
|
|
835
|
+
// Returns the resolved supported tag (in its catalog casing) or
|
|
836
|
+
// null when no chain reaches a supported entry.
|
|
837
|
+
async function _resolveCandidate(candidate, supported) {
|
|
838
|
+
if (!candidate) return null;
|
|
839
|
+
var lowered = candidate.toLowerCase();
|
|
840
|
+
var supportedLowerToOriginal = Object.create(null);
|
|
841
|
+
for (var i = 0; i < supported.length; i += 1) {
|
|
842
|
+
supportedLowerToOriginal[supported[i].toLowerCase()] = supported[i];
|
|
843
|
+
}
|
|
844
|
+
if (Object.prototype.hasOwnProperty.call(supportedLowerToOriginal, lowered)) {
|
|
845
|
+
return supportedLowerToOriginal[lowered];
|
|
846
|
+
}
|
|
847
|
+
// Primary-subtag match. `en-US` candidate matches `en` supported.
|
|
848
|
+
var candidatePrimary = lowered.split("-")[0];
|
|
849
|
+
for (var j = 0; j < supported.length; j += 1) {
|
|
850
|
+
var supportedTag = supported[j];
|
|
851
|
+
var supLower = supportedTag.toLowerCase();
|
|
852
|
+
if (supLower === candidatePrimary) return supportedTag;
|
|
853
|
+
}
|
|
854
|
+
// Walk the catalog fallback chain. Bounded depth so a manually
|
|
855
|
+
// poked self-loop doesn't spin forever (the schema FK is best-
|
|
856
|
+
// effort, and the application validators refuse `tag === fallback`
|
|
857
|
+
// but can't refuse `A -> B -> A` without traversing the chain).
|
|
858
|
+
var current = candidate;
|
|
859
|
+
for (var depth = 0; depth < FALLBACK_MAX_DEPTH; depth += 1) {
|
|
860
|
+
var row = await _readLocale(current);
|
|
861
|
+
if (!row || row.fallback == null) return null;
|
|
862
|
+
var nextLower = String(row.fallback).toLowerCase();
|
|
863
|
+
if (Object.prototype.hasOwnProperty.call(supportedLowerToOriginal, nextLower)) {
|
|
864
|
+
return supportedLowerToOriginal[nextLower];
|
|
865
|
+
}
|
|
866
|
+
// Also check primary-subtag of the fallback hop.
|
|
867
|
+
var nextPrimary = nextLower.split("-")[0];
|
|
868
|
+
for (var k = 0; k < supported.length; k += 1) {
|
|
869
|
+
if (supported[k].toLowerCase() === nextPrimary) return supported[k];
|
|
870
|
+
}
|
|
871
|
+
current = row.fallback;
|
|
872
|
+
}
|
|
873
|
+
return null;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function _canonicalForUrl(host, path, locale, strategy) {
|
|
877
|
+
// We emit a canonical URL only for the URL-shaped strategies.
|
|
878
|
+
if (strategy !== "url_prefix" && strategy !== "subdomain") return undefined;
|
|
879
|
+
if (strategy === "url_prefix") {
|
|
880
|
+
// Replace any leading locale-shaped path segment with the
|
|
881
|
+
// resolved locale, otherwise prepend it.
|
|
882
|
+
var prefix = _urlPrefixCandidate(path);
|
|
883
|
+
var rest;
|
|
884
|
+
if (prefix) {
|
|
885
|
+
rest = path.slice(1 + prefix.length); // strip "/<prefix>"
|
|
886
|
+
if (rest.length === 0) rest = "/";
|
|
887
|
+
} else {
|
|
888
|
+
rest = path;
|
|
889
|
+
}
|
|
890
|
+
if (rest.charAt(0) !== "/") rest = "/" + rest;
|
|
891
|
+
return "https://" + host + "/" + locale.toLowerCase() + (rest === "/" ? "" : rest);
|
|
892
|
+
}
|
|
893
|
+
// subdomain — replace any leading locale-shaped subdomain
|
|
894
|
+
// segment with the resolved locale, otherwise prepend it. The
|
|
895
|
+
// canonical host carries the locale subdomain even when the
|
|
896
|
+
// request landed on the apex (operators expect a redirect to
|
|
897
|
+
// the canonical form).
|
|
898
|
+
var sub = _subdomainCandidate(host);
|
|
899
|
+
var apex;
|
|
900
|
+
if (sub) {
|
|
901
|
+
apex = host.slice(sub.length + 1);
|
|
902
|
+
} else {
|
|
903
|
+
apex = host;
|
|
904
|
+
}
|
|
905
|
+
return "https://" + locale.toLowerCase() + "." + apex + path;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
async function resolveLocale(input) {
|
|
909
|
+
if (!input || typeof input !== "object") {
|
|
910
|
+
throw new TypeError("localeRouter.resolveLocale: input object required");
|
|
911
|
+
}
|
|
912
|
+
var req = input.request;
|
|
913
|
+
if (!req || typeof req !== "object") {
|
|
914
|
+
throw new TypeError("localeRouter.resolveLocale: request object required");
|
|
915
|
+
}
|
|
916
|
+
var host = _host(req.host, "request.host");
|
|
917
|
+
var path = _path(req.path, "request.path");
|
|
918
|
+
var acceptLanguage = _headerOpt(req.accept_language, "request.accept_language");
|
|
919
|
+
var cookieLocale = _headerOpt(req.cookie_locale, "request.cookie_locale");
|
|
920
|
+
var customerId = req.customer_id == null
|
|
921
|
+
? null
|
|
922
|
+
: _customerId(req.customer_id, "request.customer_id");
|
|
923
|
+
|
|
924
|
+
// Per-call validation that the cookie hint (when supplied) is
|
|
925
|
+
// shaped like a locale tag — operators rotating a stale cookie
|
|
926
|
+
// shouldn't crash the resolver.
|
|
927
|
+
if (cookieLocale != null && (!LOCALE_RE.test(cookieLocale) || cookieLocale.length > LOCALE_MAX)) {
|
|
928
|
+
cookieLocale = null;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
var policy = await activePolicy();
|
|
932
|
+
if (!policy) {
|
|
933
|
+
// No active policy — nothing to resolve against. Operators
|
|
934
|
+
// call setActivePolicy at boot; a deployment that hasn't done
|
|
935
|
+
// so yet gets a clear refusal instead of a silent default.
|
|
936
|
+
throw new Error(
|
|
937
|
+
"localeRouter.resolveLocale: no active policy — call setActivePolicy first"
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
var supported = policy.supported_locales;
|
|
941
|
+
var strategy = policy.strategy;
|
|
942
|
+
var defaultLocale = policy.default_locale;
|
|
943
|
+
|
|
944
|
+
// Step 1 — customer_id always wins when a row exists. Strategy
|
|
945
|
+
// `customer_preference` would otherwise have no source of truth
|
|
946
|
+
// other than this row; for every other strategy, an explicit
|
|
947
|
+
// logged-in pick is the truthiest signal we have.
|
|
948
|
+
if (customerId) {
|
|
949
|
+
var pref = await _readCustomerPref(customerId);
|
|
950
|
+
if (pref) {
|
|
951
|
+
var custResolved = await _resolveCandidate(pref.locale, supported);
|
|
952
|
+
if (custResolved) {
|
|
953
|
+
return await _emitResolution(custResolved, "customer_preference", host, path, strategy);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Step 2 — strategy-specific request hint.
|
|
959
|
+
var hintLocale = null;
|
|
960
|
+
var hintSource = null;
|
|
961
|
+
if (strategy === "url_prefix") {
|
|
962
|
+
hintLocale = _urlPrefixCandidate(path);
|
|
963
|
+
hintSource = "url_prefix";
|
|
964
|
+
} else if (strategy === "subdomain") {
|
|
965
|
+
hintLocale = _subdomainCandidate(host);
|
|
966
|
+
hintSource = "subdomain";
|
|
967
|
+
} else if (strategy === "cookie") {
|
|
968
|
+
hintLocale = cookieLocale;
|
|
969
|
+
hintSource = "cookie";
|
|
970
|
+
}
|
|
971
|
+
if (hintLocale) {
|
|
972
|
+
var hintResolved = await _resolveCandidate(hintLocale, supported);
|
|
973
|
+
if (hintResolved) {
|
|
974
|
+
return await _emitResolution(hintResolved, hintSource, host, path, strategy);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// Step 3 — Accept-Language walk. Every strategy falls back through
|
|
979
|
+
// the browser's stated preference list before landing on the
|
|
980
|
+
// policy default.
|
|
981
|
+
if (acceptLanguage) {
|
|
982
|
+
var alEntries = _parseAcceptLanguage(acceptLanguage);
|
|
983
|
+
for (var i = 0; i < alEntries.length; i += 1) {
|
|
984
|
+
var alResolved = await _resolveCandidate(alEntries[i].tag, supported);
|
|
985
|
+
if (alResolved) {
|
|
986
|
+
return await _emitResolution(alResolved, "accept_language", host, path, strategy);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Step 4 — the policy's default locale.
|
|
992
|
+
return await _emitResolution(defaultLocale, "default", host, path, strategy);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
async function _emitResolution(locale, source, host, path, strategy) {
|
|
996
|
+
var out = { locale: locale, source: source };
|
|
997
|
+
var canonical = _canonicalForUrl(host, path, locale, strategy);
|
|
998
|
+
if (canonical !== undefined) out.canonical_url = canonical;
|
|
999
|
+
|
|
1000
|
+
var id = _b().uuid.v7();
|
|
1001
|
+
var ts = _clampLog(_now());
|
|
1002
|
+
await query(
|
|
1003
|
+
"INSERT INTO locale_resolutions_log " +
|
|
1004
|
+
"(id, locale, source, host, path, occurred_at) " +
|
|
1005
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
1006
|
+
[id, locale, source, host, path, ts]
|
|
1007
|
+
);
|
|
1008
|
+
return out;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// ---- popularity / audit --------------------------------------------
|
|
1012
|
+
|
|
1013
|
+
async function localePopularity(input) {
|
|
1014
|
+
if (!input || typeof input !== "object") {
|
|
1015
|
+
throw new TypeError("localeRouter.localePopularity: input object required");
|
|
1016
|
+
}
|
|
1017
|
+
var from = _epochMs(input.from, "from");
|
|
1018
|
+
var to = _epochMs(input.to, "to");
|
|
1019
|
+
var limit = _limit(input.limit, "limit");
|
|
1020
|
+
if (from != null && to != null && from > to) {
|
|
1021
|
+
throw new TypeError("localeRouter.localePopularity: from must be <= to");
|
|
1022
|
+
}
|
|
1023
|
+
var sql = "SELECT locale, COUNT(*) AS hits FROM locale_resolutions_log";
|
|
1024
|
+
var params = [];
|
|
1025
|
+
var where = [];
|
|
1026
|
+
if (from != null) {
|
|
1027
|
+
where.push("occurred_at >= ?" + (params.length + 1));
|
|
1028
|
+
params.push(from);
|
|
1029
|
+
}
|
|
1030
|
+
if (to != null) {
|
|
1031
|
+
where.push("occurred_at <= ?" + (params.length + 1));
|
|
1032
|
+
params.push(to);
|
|
1033
|
+
}
|
|
1034
|
+
if (where.length) sql += " WHERE " + where.join(" AND ");
|
|
1035
|
+
sql += " GROUP BY locale ORDER BY hits DESC, locale ASC LIMIT ?" +
|
|
1036
|
+
(params.length + 1);
|
|
1037
|
+
params.push(limit);
|
|
1038
|
+
var r = await query(sql, params);
|
|
1039
|
+
var out = [];
|
|
1040
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
1041
|
+
out.push({
|
|
1042
|
+
locale: r.rows[i].locale,
|
|
1043
|
+
hits: Number(r.rows[i].hits),
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
return out;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
return {
|
|
1050
|
+
KINDS: KINDS.slice(),
|
|
1051
|
+
STRATEGIES: STRATEGIES.slice(),
|
|
1052
|
+
LOCALE_RE: LOCALE_RE,
|
|
1053
|
+
CURRENCY_RE: CURRENCY_RE,
|
|
1054
|
+
defineLocale: defineLocale,
|
|
1055
|
+
getLocale: getLocale,
|
|
1056
|
+
listLocales: listLocales,
|
|
1057
|
+
definePolicy: definePolicy,
|
|
1058
|
+
getPolicy: getPolicy,
|
|
1059
|
+
listPolicies: listPolicies,
|
|
1060
|
+
archivePolicy: archivePolicy,
|
|
1061
|
+
setActivePolicy: setActivePolicy,
|
|
1062
|
+
activePolicy: activePolicy,
|
|
1063
|
+
setCustomerLocale: setCustomerLocale,
|
|
1064
|
+
clearCustomerLocale: clearCustomerLocale,
|
|
1065
|
+
getCustomerLocale: getCustomerLocale,
|
|
1066
|
+
resolveLocale: resolveLocale,
|
|
1067
|
+
localePopularity: localePopularity,
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
module.exports = {
|
|
1072
|
+
create: create,
|
|
1073
|
+
KINDS: KINDS,
|
|
1074
|
+
STRATEGIES: STRATEGIES,
|
|
1075
|
+
LOCALE_RE: LOCALE_RE,
|
|
1076
|
+
CURRENCY_RE: CURRENCY_RE,
|
|
1077
|
+
};
|