@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/lib/assembly-instructions.js +777 -0
  3. package/lib/auto-replenish.js +933 -0
  4. package/lib/business-hours.js +980 -0
  5. package/lib/click-and-collect.js +711 -0
  6. package/lib/clickstream.js +713 -0
  7. package/lib/cost-layers.js +774 -0
  8. package/lib/credit-limits.js +752 -0
  9. package/lib/currency-rounding.js +525 -0
  10. package/lib/customer-activity.js +862 -0
  11. package/lib/customer-notes.js +712 -0
  12. package/lib/customer-risk-profile.js +593 -0
  13. package/lib/customer-surveys.js +1012 -0
  14. package/lib/damage-photos.js +473 -0
  15. package/lib/discount-allocation.js +557 -0
  16. package/lib/dropship-forwarding.js +645 -0
  17. package/lib/email-templates.js +817 -0
  18. package/lib/index.js +45 -0
  19. package/lib/inventory-allocations.js +559 -0
  20. package/lib/inventory-writeoffs.js +636 -0
  21. package/lib/knowledge-base.js +1104 -0
  22. package/lib/locale-router.js +1077 -0
  23. package/lib/operator-roles.js +768 -0
  24. package/lib/order-escalation.js +951 -0
  25. package/lib/order-ratings.js +495 -0
  26. package/lib/order-tags.js +944 -0
  27. package/lib/packing-slips.js +810 -0
  28. package/lib/payment-retries.js +816 -0
  29. package/lib/pick-lists.js +639 -0
  30. package/lib/pixel-events.js +995 -0
  31. package/lib/preorder.js +595 -0
  32. package/lib/print-queue.js +681 -0
  33. package/lib/product-qa.js +749 -0
  34. package/lib/promo-bundles.js +835 -0
  35. package/lib/push-notifications.js +937 -0
  36. package/lib/refund-automation.js +853 -0
  37. package/lib/reorder-reminders.js +798 -0
  38. package/lib/robots-config.js +753 -0
  39. package/lib/seller-signup.js +1052 -0
  40. package/lib/site-redirects.js +690 -0
  41. package/lib/sitemap-generator.js +717 -0
  42. package/lib/subscription-gifts.js +710 -0
  43. package/lib/tax-cert-renewals.js +632 -0
  44. package/lib/theme-assets.js +711 -0
  45. package/lib/tier-benefits.js +776 -0
  46. package/lib/vendor/MANIFEST.json +2 -2
  47. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  48. package/lib/vendor/blamejs/api-snapshot.json +2 -2
  49. package/lib/vendor/blamejs/lib/metrics.js +68 -4
  50. package/lib/vendor/blamejs/package.json +1 -1
  51. package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
  52. package/lib/wishlist-alerts.js +842 -0
  53. package/lib/wishlist-sharing.js +718 -0
  54. package/package.json +1 -1
@@ -0,0 +1,1104 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.knowledgeBase
4
+ * @title Knowledge base — self-serve customer help center
5
+ *
6
+ * @intro
7
+ * FAQ articles grouped by category, with locale fallback, search-
8
+ * suggest ranking, view tracking, and helpfulness voting. Composed
9
+ * by the supportTickets primitive's intake flow: the storefront
10
+ * calls `searchSuggest({ query })` against the customer's draft
11
+ * subject + body and surfaces ranked articles before the ticket
12
+ * form actually submits. A customer who finds their answer in the
13
+ * knowledge base closes the loop without ever opening a ticket.
14
+ *
15
+ * Body is authored in the in-process Markdown subset shared with
16
+ * cmsBlocks / storefrontPages. Every text run is HTML-escaped via
17
+ * `b.template.escapeHtml`; every link URL passes through
18
+ * `b.safeUrl.parse` (https-only) OR an allow-list for `/`-rooted
19
+ * absolute paths. Raw HTML in the body never reaches the rendered
20
+ * output — any `<` lands as `&lt;`. The raw body lives in storage;
21
+ * the rendered HTML is computed on demand at read time.
22
+ *
23
+ * Locale fallback walks the requested BCP-47 tag right-to-left,
24
+ * dropping trailing subtags, then falls back to the operator's
25
+ * configured default locale (`opts.defaultLocale`, default `en`).
26
+ * "fr-ca" -> ["fr-ca", "fr", "en"]. First (slug, locale) match
27
+ * wins.
28
+ *
29
+ * Helpfulness voting is deduped at the (slug, session_id_hash)
30
+ * UNIQUE — a repeat vote from the same session collapses to a
31
+ * no-op so the aggregate counters reflect distinct sessions
32
+ * rather than refresh-loop noise. session_id is hashed via
33
+ * `b.crypto.namespaceHash("kb-vote-session", id)` (and the parallel
34
+ * "kb-view-session" namespace on view rows) so the raw session id
35
+ * never lands on disk.
36
+ *
37
+ * `searchSuggest` ranks candidates with three weighted signals:
38
+ * title-token match (weight 3), tag-token match (weight 2), and
39
+ * body-token match (weight 1). Token equality is case-insensitive
40
+ * ASCII; tokens come from a /\s+/ split of the query after lower-
41
+ * casing. Archived + unpublished articles never appear in the
42
+ * ranked output.
43
+ *
44
+ * Composes:
45
+ * - `b.template.escapeHtml` — render-time text escape
46
+ * - `b.safeUrl.parse` — render-time link gate (https://)
47
+ * - `b.crypto.namespaceHash` — session-id hashing
48
+ * - `b.uuid.v7` — row ids on votes + views
49
+ * - `b.pagination` — HMAC-tagged tuple cursor for
50
+ * listArticles
51
+ *
52
+ * Monotonic per-process clock: two writes in the same millisecond
53
+ * would tie on `updated_at` / `occurred_at` and make a sort-by-
54
+ * timestamp read ambiguous. `_now` bumps to `prior + 1` on
55
+ * collision so the (updated_at DESC, slug DESC) listArticles
56
+ * cursor + popularArticles aggregation carry a strict per-process
57
+ * ordering.
58
+ *
59
+ * Surface:
60
+ * - defineArticle({ slug, title, body, category, tags?, locale,
61
+ * published? })
62
+ * - getArticle({ slug, locale? })
63
+ * - listArticles({ category?, search?, published_only?, cursor?,
64
+ * limit? })
65
+ * - updateArticle(slug, patch)
66
+ * - publishArticle(slug)
67
+ * - unpublishArticle(slug)
68
+ * - archiveArticle(slug)
69
+ * - recordView({ slug, session_id?, customer_id? })
70
+ * - recordVote({ slug, session_id, vote })
71
+ * - voteAggregateForArticle(slug)
72
+ * - popularArticles({ from, to, limit })
73
+ * - searchSuggest({ query, limit, locale?, category? })
74
+ *
75
+ * Storage:
76
+ * - kb_articles, kb_views, kb_votes (migration
77
+ * `0162_knowledge_base.sql`).
78
+ *
79
+ * @primitive knowledgeBase
80
+ * @related b.template.escapeHtml, b.safeUrl, b.crypto.namespaceHash,
81
+ * b.uuid.v7, shop.supportTickets, shop.cmsBlocks
82
+ */
83
+
84
+ var MAX_SLUG_LEN = 120;
85
+ var MAX_TITLE_LEN = 200;
86
+ var MAX_BODY_LEN = 32000;
87
+ var MAX_CATEGORY_LEN = 80;
88
+ var MAX_TAG_LEN = 40;
89
+ var MAX_TAG_COUNT = 24;
90
+ var MAX_LOCALE_LEN = 35;
91
+ var MAX_QUERY_LEN = 400;
92
+ var MAX_LIST_LIMIT = 100;
93
+ var DEFAULT_LIST_LIMIT = 25;
94
+ var MAX_POPULAR_LIMIT = 100;
95
+ var DEFAULT_POPULAR_LIMIT = 10;
96
+ var MAX_SUGGEST_LIMIT = 25;
97
+ var DEFAULT_SUGGEST_LIMIT = 5;
98
+
99
+ var VIEW_NAMESPACE = "kb-view-session";
100
+ var VOTE_NAMESPACE = "kb-vote-session";
101
+
102
+ var ALLOWED_VOTES = ["helpful", "not_helpful"];
103
+
104
+ var LIST_ORDER_KEY = ["updated_at:desc", "slug:desc"];
105
+
106
+ var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
107
+ var LOCALE_RE = /^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/;
108
+ var CATEGORY_RE = /^[a-z][a-z0-9_-]*$/;
109
+ var TAG_RE = /^[a-z][a-z0-9_-]*$/;
110
+
111
+ var CONTROL_BYTE_BODY_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
112
+ var CONTROL_BYTE_STRICT_RE = /[\x00-\x1f\x7f]/;
113
+ var ZERO_WIDTH_RE = new RegExp(
114
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
115
+ );
116
+
117
+ var SEARCH_WEIGHTS = Object.freeze({
118
+ title: 3,
119
+ tag: 2,
120
+ body: 1,
121
+ });
122
+
123
+ var bShop;
124
+ function _b() {
125
+ if (!bShop) bShop = require("./index");
126
+ return bShop.framework;
127
+ }
128
+
129
+ // ---- monotonic clock ---------------------------------------------------
130
+ //
131
+ // Operator-driven writes can land in the same millisecond on fast
132
+ // machines. Bumping by 1ms on a tie keeps the timeline strictly
133
+ // increasing so a sort-by-timestamp read returns events in the
134
+ // order they were issued.
135
+
136
+ var _lastTs = 0;
137
+ function _now() {
138
+ var t = Date.now();
139
+ if (t <= _lastTs) t = _lastTs + 1;
140
+ _lastTs = t;
141
+ return t;
142
+ }
143
+
144
+ // ---- validators --------------------------------------------------------
145
+
146
+ function _slug(s) {
147
+ if (typeof s !== "string" || !s.length) {
148
+ throw new TypeError("knowledgeBase: slug must be a non-empty string");
149
+ }
150
+ if (s.length > MAX_SLUG_LEN) {
151
+ throw new TypeError("knowledgeBase: slug must be <= " + MAX_SLUG_LEN + " characters");
152
+ }
153
+ if (!SLUG_RE.test(s)) {
154
+ throw new TypeError("knowledgeBase: slug must match /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/");
155
+ }
156
+ return s;
157
+ }
158
+
159
+ function _title(s) {
160
+ if (typeof s !== "string") {
161
+ throw new TypeError("knowledgeBase: title must be a string");
162
+ }
163
+ var trimmed = s.trim();
164
+ if (!trimmed.length) {
165
+ throw new TypeError("knowledgeBase: title must be non-empty after trim");
166
+ }
167
+ if (s.length > MAX_TITLE_LEN) {
168
+ throw new TypeError("knowledgeBase: title must be <= " + MAX_TITLE_LEN + " characters");
169
+ }
170
+ if (CONTROL_BYTE_STRICT_RE.test(s)) {
171
+ throw new TypeError("knowledgeBase: title contains control bytes");
172
+ }
173
+ if (ZERO_WIDTH_RE.test(s)) {
174
+ throw new TypeError("knowledgeBase: title contains zero-width / direction-override characters");
175
+ }
176
+ return s;
177
+ }
178
+
179
+ function _body(s) {
180
+ if (typeof s !== "string") {
181
+ throw new TypeError("knowledgeBase: body must be a string");
182
+ }
183
+ var trimmed = s.trim();
184
+ if (!trimmed.length) {
185
+ throw new TypeError("knowledgeBase: body must be non-empty after trim");
186
+ }
187
+ if (s.length > MAX_BODY_LEN) {
188
+ throw new TypeError("knowledgeBase: body must be <= " + MAX_BODY_LEN + " characters");
189
+ }
190
+ if (CONTROL_BYTE_BODY_RE.test(s)) {
191
+ throw new TypeError("knowledgeBase: body contains control bytes");
192
+ }
193
+ if (ZERO_WIDTH_RE.test(s)) {
194
+ throw new TypeError("knowledgeBase: body contains zero-width / direction-override characters");
195
+ }
196
+ return s;
197
+ }
198
+
199
+ function _category(s) {
200
+ if (typeof s !== "string" || !s.length) {
201
+ throw new TypeError("knowledgeBase: category must be a non-empty string");
202
+ }
203
+ if (s.length > MAX_CATEGORY_LEN) {
204
+ throw new TypeError("knowledgeBase: category must be <= " + MAX_CATEGORY_LEN + " characters");
205
+ }
206
+ if (!CATEGORY_RE.test(s)) {
207
+ throw new TypeError("knowledgeBase: category must match /^[a-z][a-z0-9_-]*$/");
208
+ }
209
+ return s;
210
+ }
211
+
212
+ function _locale(s, label) {
213
+ label = label || "locale";
214
+ if (typeof s !== "string" || !s.length) {
215
+ throw new TypeError("knowledgeBase: " + label + " must be a non-empty string");
216
+ }
217
+ if (s.length > MAX_LOCALE_LEN) {
218
+ throw new TypeError("knowledgeBase: " + label + " must be <= " + MAX_LOCALE_LEN + " characters");
219
+ }
220
+ var lower = s.toLowerCase();
221
+ if (!LOCALE_RE.test(lower)) {
222
+ throw new TypeError("knowledgeBase: " + label + " must be a BCP-47 tag (e.g. 'en', 'fr-ca')");
223
+ }
224
+ return lower;
225
+ }
226
+
227
+ function _tags(input) {
228
+ if (input == null) return [];
229
+ if (!Array.isArray(input)) {
230
+ throw new TypeError("knowledgeBase: tags must be an array of strings");
231
+ }
232
+ if (input.length > MAX_TAG_COUNT) {
233
+ throw new TypeError("knowledgeBase: tags must contain <= " + MAX_TAG_COUNT + " entries");
234
+ }
235
+ var seen = {};
236
+ var out = [];
237
+ for (var i = 0; i < input.length; i += 1) {
238
+ var t = input[i];
239
+ if (typeof t !== "string" || !t.length) {
240
+ throw new TypeError("knowledgeBase: tags[" + i + "] must be a non-empty string");
241
+ }
242
+ if (t.length > MAX_TAG_LEN) {
243
+ throw new TypeError("knowledgeBase: tags[" + i + "] must be <= " + MAX_TAG_LEN + " characters");
244
+ }
245
+ if (!TAG_RE.test(t)) {
246
+ throw new TypeError("knowledgeBase: tags[" + i + "] must match /^[a-z][a-z0-9_-]*$/");
247
+ }
248
+ if (seen[t]) continue;
249
+ seen[t] = 1;
250
+ out.push(t);
251
+ }
252
+ return out;
253
+ }
254
+
255
+ function _vote(s) {
256
+ if (typeof s !== "string" || ALLOWED_VOTES.indexOf(s) === -1) {
257
+ throw new TypeError("knowledgeBase: vote must be one of " + ALLOWED_VOTES.join(", "));
258
+ }
259
+ return s;
260
+ }
261
+
262
+ function _limit(n, max, def, label) {
263
+ if (n == null) return def;
264
+ if (!Number.isInteger(n) || n <= 0 || n > max) {
265
+ throw new TypeError("knowledgeBase: " + label + " must be an integer 1..." + max);
266
+ }
267
+ return n;
268
+ }
269
+
270
+ function _timestampRange(from, to, label) {
271
+ if (!Number.isInteger(from) || from < 0) {
272
+ throw new TypeError("knowledgeBase." + label + ": from must be a non-negative integer (ms epoch)");
273
+ }
274
+ if (!Number.isInteger(to) || to < 0) {
275
+ throw new TypeError("knowledgeBase." + label + ": to must be a non-negative integer (ms epoch)");
276
+ }
277
+ if (from > to) {
278
+ throw new TypeError("knowledgeBase." + label + ": from must be <= to");
279
+ }
280
+ }
281
+
282
+ function _queryStr(s) {
283
+ if (typeof s !== "string") {
284
+ throw new TypeError("knowledgeBase: query must be a string");
285
+ }
286
+ if (s.length > MAX_QUERY_LEN) {
287
+ throw new TypeError("knowledgeBase: query must be <= " + MAX_QUERY_LEN + " characters");
288
+ }
289
+ if (CONTROL_BYTE_BODY_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
290
+ throw new TypeError("knowledgeBase: query contains control / zero-width bytes");
291
+ }
292
+ return s;
293
+ }
294
+
295
+ function _customerId(s) {
296
+ try {
297
+ return _b().guardUuid.sanitize(s, { profile: "strict" });
298
+ } catch (e) {
299
+ throw new TypeError("knowledgeBase: customer_id — " + (e && e.message || "invalid UUID"));
300
+ }
301
+ }
302
+
303
+ function _sessionIdRaw(s) {
304
+ if (typeof s !== "string" || !s.length) {
305
+ throw new TypeError("knowledgeBase: session_id must be a non-empty string");
306
+ }
307
+ if (s.length > 256) {
308
+ throw new TypeError("knowledgeBase: session_id must be <= 256 characters");
309
+ }
310
+ if (CONTROL_BYTE_STRICT_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
311
+ throw new TypeError("knowledgeBase: session_id contains control / zero-width bytes");
312
+ }
313
+ return s;
314
+ }
315
+
316
+ // ---- locale fallback ---------------------------------------------------
317
+ //
318
+ // Walk a canonical BCP-47 locale right-to-left, dropping trailing
319
+ // subtags, then append the configured default locale (when distinct).
320
+ // "fr-ca" + default "en" -> ["fr-ca", "fr", "en"].
321
+ function _fallbackChain(locale, defaultLocale) {
322
+ var chain = [];
323
+ var cur = locale;
324
+ while (cur && cur.length) {
325
+ chain.push(cur);
326
+ var idx = cur.lastIndexOf("-");
327
+ if (idx === -1) break;
328
+ cur = cur.slice(0, idx);
329
+ }
330
+ if (defaultLocale && chain.indexOf(defaultLocale) === -1) {
331
+ chain.push(defaultLocale);
332
+ }
333
+ return chain;
334
+ }
335
+
336
+ // ---- Markdown to HTML --------------------------------------------------
337
+ //
338
+ // Minimal in-process Markdown subset, hand-written to keep the
339
+ // primitive in the zero-runtime-deps envelope. Mirrors cmsBlocks /
340
+ // storefrontPages: paragraphs, headings, lists, links, inline code,
341
+ // emphasis, blockquotes, horizontal rules. Every text run is HTML-
342
+ // escaped via `b.template.escapeHtml`. Every link URL passes through
343
+ // `b.safeUrl.parse` (https-only) OR an allow-list for `/`-rooted
344
+ // absolute paths. Any URL that fails the gate is dropped from the
345
+ // rendered HTML; the anchor text falls back to inert escaped text.
346
+ // Raw HTML in the body is never passed through.
347
+
348
+ function _esc(s) {
349
+ return _b().template.escapeHtml(s);
350
+ }
351
+
352
+ function _safeLinkUrl(url) {
353
+ if (typeof url !== "string" || !url.length || url.length > 2048) return null;
354
+ if (CONTROL_BYTE_BODY_RE.test(url) || ZERO_WIDTH_RE.test(url)) return null;
355
+ if (url.charCodeAt(0) === 47 /* "/" */) {
356
+ if (url.length > 1 && url.charCodeAt(1) === 47) return null;
357
+ if (url.indexOf("..") !== -1) return null;
358
+ return url;
359
+ }
360
+ try {
361
+ _b().safeUrl.parse(url, { allowedProtocols: ["https:"] });
362
+ } catch (_e) {
363
+ return null;
364
+ }
365
+ return url;
366
+ }
367
+
368
+ function _renderInline(line) {
369
+ var out = "";
370
+ var i = 0;
371
+ while (i < line.length) {
372
+ var ch = line.charAt(i);
373
+ if (ch === "`") {
374
+ var end = line.indexOf("`", i + 1);
375
+ if (end !== -1) {
376
+ out += "<code>" + _esc(line.slice(i + 1, end)) + "</code>";
377
+ i = end + 1;
378
+ continue;
379
+ }
380
+ }
381
+ if (ch === "[") {
382
+ var closeBracket = line.indexOf("]", i + 1);
383
+ if (closeBracket !== -1 && line.charAt(closeBracket + 1) === "(") {
384
+ var closeParen = line.indexOf(")", closeBracket + 2);
385
+ if (closeParen !== -1) {
386
+ var text = line.slice(i + 1, closeBracket);
387
+ var url = line.slice(closeBracket + 2, closeParen);
388
+ var safe = _safeLinkUrl(url);
389
+ if (safe) {
390
+ out += '<a href="' + _esc(safe) + '">' + _renderInline(text) + "</a>";
391
+ } else {
392
+ out += _renderInline(text);
393
+ }
394
+ i = closeParen + 1;
395
+ continue;
396
+ }
397
+ }
398
+ }
399
+ if (ch === "*" && line.charAt(i + 1) === "*") {
400
+ var endBold = line.indexOf("**", i + 2);
401
+ if (endBold !== -1) {
402
+ out += "<strong>" + _renderInline(line.slice(i + 2, endBold)) + "</strong>";
403
+ i = endBold + 2;
404
+ continue;
405
+ }
406
+ }
407
+ if (ch === "*" || ch === "_") {
408
+ var endItalic = line.indexOf(ch, i + 1);
409
+ if (endItalic !== -1 && endItalic !== i + 1) {
410
+ out += "<em>" + _renderInline(line.slice(i + 1, endItalic)) + "</em>";
411
+ i = endItalic + 1;
412
+ continue;
413
+ }
414
+ }
415
+ out += _esc(ch);
416
+ i += 1;
417
+ }
418
+ return out;
419
+ }
420
+
421
+ function _renderMarkdown(body) {
422
+ var normalized = String(body).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
423
+ var lines = normalized.split("\n");
424
+ var out = [];
425
+ var i = 0;
426
+ while (i < lines.length) {
427
+ var line = lines[i];
428
+ if (line.trim() === "") { i += 1; continue; }
429
+ if (/^-{3,}\s*$/.test(line)) {
430
+ out.push("<hr />");
431
+ i += 1;
432
+ continue;
433
+ }
434
+ var hMatch = /^(#{1,6})\s+(.*)$/.exec(line);
435
+ if (hMatch) {
436
+ var level = hMatch[1].length;
437
+ out.push("<h" + level + ">" + _renderInline(hMatch[2].trim()) + "</h" + level + ">");
438
+ i += 1;
439
+ continue;
440
+ }
441
+ if (/^>\s?/.test(line)) {
442
+ var quoteLines = [];
443
+ while (i < lines.length && /^>\s?/.test(lines[i])) {
444
+ quoteLines.push(lines[i].replace(/^>\s?/, ""));
445
+ i += 1;
446
+ }
447
+ out.push("<blockquote><p>" + _renderInline(quoteLines.join(" ")) + "</p></blockquote>");
448
+ continue;
449
+ }
450
+ if (/^[-*]\s+/.test(line)) {
451
+ var ulItems = [];
452
+ while (i < lines.length && /^[-*]\s+/.test(lines[i])) {
453
+ ulItems.push(lines[i].replace(/^[-*]\s+/, ""));
454
+ i += 1;
455
+ }
456
+ var ulHtml = ulItems.map(function (item) {
457
+ return "<li>" + _renderInline(item) + "</li>";
458
+ }).join("");
459
+ out.push("<ul>" + ulHtml + "</ul>");
460
+ continue;
461
+ }
462
+ if (/^\d+\.\s+/.test(line)) {
463
+ var olItems = [];
464
+ while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
465
+ olItems.push(lines[i].replace(/^\d+\.\s+/, ""));
466
+ i += 1;
467
+ }
468
+ var olHtml = olItems.map(function (item) {
469
+ return "<li>" + _renderInline(item) + "</li>";
470
+ }).join("");
471
+ out.push("<ol>" + olHtml + "</ol>");
472
+ continue;
473
+ }
474
+ var paraLines = [line];
475
+ i += 1;
476
+ while (
477
+ i < lines.length &&
478
+ lines[i].trim() !== "" &&
479
+ !/^#{1,6}\s+/.test(lines[i]) &&
480
+ !/^[-*]\s+/.test(lines[i]) &&
481
+ !/^\d+\.\s+/.test(lines[i]) &&
482
+ !/^>\s?/.test(lines[i]) &&
483
+ !/^-{3,}\s*$/.test(lines[i])
484
+ ) {
485
+ paraLines.push(lines[i]);
486
+ i += 1;
487
+ }
488
+ out.push("<p>" + _renderInline(paraLines.join(" ")) + "</p>");
489
+ }
490
+ return out.join("\n");
491
+ }
492
+
493
+ // ---- hydration ---------------------------------------------------------
494
+
495
+ function _hydrateRow(row) {
496
+ if (!row) return null;
497
+ var tags;
498
+ try { tags = JSON.parse(row.tags_json || "[]"); }
499
+ catch (_e) { tags = []; }
500
+ return {
501
+ slug: row.slug,
502
+ locale: row.locale,
503
+ title: row.title,
504
+ body: row.body,
505
+ category: row.category,
506
+ tags: tags,
507
+ published: row.published === 1 || row.published === true,
508
+ archived_at: row.archived_at == null ? null : Number(row.archived_at),
509
+ view_count: Number(row.view_count) || 0,
510
+ helpful_count: Number(row.helpful_count) || 0,
511
+ not_helpful_count: Number(row.not_helpful_count) || 0,
512
+ created_at: Number(row.created_at),
513
+ updated_at: Number(row.updated_at),
514
+ body_html: _renderMarkdown(row.body),
515
+ };
516
+ }
517
+
518
+ // ---- search tokenizer --------------------------------------------------
519
+ //
520
+ // Lowercase + non-alphanum split + drop tokens shorter than 2
521
+ // chars. Keeps the ranker simple + locale-agnostic enough for the
522
+ // in-process search. Operator-supplied query strings are already
523
+ // gated by `_queryStr()` so no control bytes reach this far.
524
+
525
+ function _tokenize(s) {
526
+ var lower = String(s).toLowerCase();
527
+ var raw = lower.split(/[^a-z0-9]+/);
528
+ var out = [];
529
+ for (var i = 0; i < raw.length; i += 1) {
530
+ if (raw[i].length >= 2) out.push(raw[i]);
531
+ }
532
+ return out;
533
+ }
534
+
535
+ // ---- factory -----------------------------------------------------------
536
+
537
+ function create(opts) {
538
+ opts = opts || {};
539
+ var query = opts.query;
540
+ if (!query) {
541
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
542
+ }
543
+ var defaultLocale = opts.defaultLocale == null
544
+ ? "en"
545
+ : _locale(opts.defaultLocale, "defaultLocale");
546
+
547
+ if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
548
+ if (process.env.NODE_ENV === "production") {
549
+ throw new Error("knowledgeBase.create: opts.cursorSecret is required in production");
550
+ }
551
+ opts.cursorSecret = "knowledge-base-cursor-secret-dev-only";
552
+ }
553
+ var cursorSecret = opts.cursorSecret;
554
+
555
+ function _decodeCursor(cursor, label) {
556
+ if (cursor == null) return null;
557
+ if (typeof cursor !== "string") {
558
+ throw new TypeError("knowledgeBase." + label + ": cursor must be an opaque string or null");
559
+ }
560
+ try {
561
+ var state = _b().pagination.decodeCursor(cursor, cursorSecret);
562
+ if (JSON.stringify(state.orderKey) !== JSON.stringify(LIST_ORDER_KEY)) {
563
+ throw new TypeError("knowledgeBase." + label + ": cursor orderKey mismatch");
564
+ }
565
+ return state.vals;
566
+ } catch (e) {
567
+ if (e instanceof TypeError) throw e;
568
+ throw new TypeError("knowledgeBase." + label + ": cursor — " + (e && e.message || "malformed"));
569
+ }
570
+ }
571
+
572
+ function _encodeNext(rows, limit) {
573
+ var last = rows[rows.length - 1];
574
+ if (!last || rows.length < limit) return null;
575
+ return _b().pagination.encodeCursor({
576
+ orderKey: LIST_ORDER_KEY,
577
+ vals: [last.updated_at, last.slug],
578
+ forward: true,
579
+ }, cursorSecret);
580
+ }
581
+
582
+ function _hashSession(namespace, sessionId) {
583
+ return _b().crypto.namespaceHash(namespace, sessionId);
584
+ }
585
+
586
+ async function _readExact(slug, locale) {
587
+ var r = await query(
588
+ "SELECT * FROM kb_articles WHERE slug = ?1 AND locale = ?2",
589
+ [slug, locale],
590
+ );
591
+ return r.rows[0] || null;
592
+ }
593
+
594
+ async function _readAllLocales(slug) {
595
+ var r = await query(
596
+ "SELECT * FROM kb_articles WHERE slug = ?1",
597
+ [slug],
598
+ );
599
+ return r.rows;
600
+ }
601
+
602
+ // Locale-aware single-article read. Walks the fallback chain;
603
+ // returns the first non-archived row. Archived rows are filtered
604
+ // even at the exact-locale match — once a slug is archived, no
605
+ // locale survives. `published_only=true` additionally filters
606
+ // unpublished rows.
607
+ async function _readWithFallback(slug, locale, publishedOnly) {
608
+ var chain = _fallbackChain(locale, defaultLocale);
609
+ for (var i = 0; i < chain.length; i += 1) {
610
+ var row = await _readExact(slug, chain[i]);
611
+ if (!row) continue;
612
+ if (row.archived_at != null) continue;
613
+ if (publishedOnly && row.published !== 1) continue;
614
+ return row;
615
+ }
616
+ return null;
617
+ }
618
+
619
+ return {
620
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
621
+ MAX_TITLE_LEN: MAX_TITLE_LEN,
622
+ MAX_BODY_LEN: MAX_BODY_LEN,
623
+ MAX_CATEGORY_LEN: MAX_CATEGORY_LEN,
624
+ MAX_TAG_LEN: MAX_TAG_LEN,
625
+ MAX_TAG_COUNT: MAX_TAG_COUNT,
626
+ MAX_LOCALE_LEN: MAX_LOCALE_LEN,
627
+ MAX_QUERY_LEN: MAX_QUERY_LEN,
628
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
629
+ MAX_POPULAR_LIMIT: MAX_POPULAR_LIMIT,
630
+ MAX_SUGGEST_LIMIT: MAX_SUGGEST_LIMIT,
631
+ ALLOWED_VOTES: ALLOWED_VOTES.slice(),
632
+ SEARCH_WEIGHTS: Object.assign({}, SEARCH_WEIGHTS),
633
+ defaultLocale: defaultLocale,
634
+
635
+ // Idempotent insert / update of a single (slug, locale) row.
636
+ // The first defineArticle for a slug establishes the canonical
637
+ // category + tags; subsequent locale rows inherit those slug-
638
+ // wide attributes — operator-supplied category/tags on a
639
+ // localized re-author must match the existing slug, otherwise
640
+ // the call is refused (mismatched metadata across locales is
641
+ // an authoring bug). `published` defaults to false on first
642
+ // insert; existing rows keep their current published flag
643
+ // (use publishArticle / unpublishArticle to flip).
644
+ defineArticle: async function (input) {
645
+ if (!input || typeof input !== "object") {
646
+ throw new TypeError("knowledgeBase.defineArticle: input object required");
647
+ }
648
+ var slug = _slug(input.slug);
649
+ var title = _title(input.title);
650
+ var body = _body(input.body);
651
+ var category = _category(input.category);
652
+ var tags = _tags(input.tags);
653
+ var locale = _locale(input.locale);
654
+ var ts = _now();
655
+
656
+ var existingAny = await _readAllLocales(slug);
657
+ if (existingAny.length) {
658
+ var canonical = existingAny[0];
659
+ if (canonical.category !== category) {
660
+ throw new TypeError(
661
+ "knowledgeBase.defineArticle: category mismatch for slug '" + slug +
662
+ "' (existing='" + canonical.category + "', supplied='" + category + "')"
663
+ );
664
+ }
665
+ var canonTags;
666
+ try { canonTags = JSON.parse(canonical.tags_json || "[]"); }
667
+ catch (_e) { canonTags = []; }
668
+ if (JSON.stringify(canonTags) !== JSON.stringify(tags)) {
669
+ throw new TypeError(
670
+ "knowledgeBase.defineArticle: tags mismatch for slug '" + slug +
671
+ "' (slug-wide tags are set on first defineArticle and must match on every locale)"
672
+ );
673
+ }
674
+ if (canonical.archived_at != null) {
675
+ throw new TypeError(
676
+ "knowledgeBase.defineArticle: slug '" + slug +
677
+ "' is archived; call publishArticle to revive before defining additional locales"
678
+ );
679
+ }
680
+ }
681
+
682
+ var existingRow = await _readExact(slug, locale);
683
+ if (existingRow) {
684
+ await query(
685
+ "UPDATE kb_articles " +
686
+ "SET title = ?1, body = ?2, category = ?3, tags_json = ?4, updated_at = ?5 " +
687
+ "WHERE slug = ?6 AND locale = ?7",
688
+ [title, body, category, JSON.stringify(tags), ts, slug, locale],
689
+ );
690
+ } else {
691
+ var published = input.published === true ? 1 : 0;
692
+ await query(
693
+ "INSERT INTO kb_articles " +
694
+ "(slug, locale, title, body, category, tags_json, published, archived_at, " +
695
+ " view_count, helpful_count, not_helpful_count, created_at, updated_at) " +
696
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, 0, 0, 0, ?8, ?8)",
697
+ [slug, locale, title, body, category, JSON.stringify(tags), published, ts],
698
+ );
699
+ }
700
+ var fresh = await _readExact(slug, locale);
701
+ return _hydrateRow(fresh);
702
+ },
703
+
704
+ getArticle: async function (input) {
705
+ if (!input || typeof input !== "object") {
706
+ throw new TypeError("knowledgeBase.getArticle: input object required");
707
+ }
708
+ var slug = _slug(input.slug);
709
+ var locale = input.locale == null ? defaultLocale : _locale(input.locale);
710
+ var row = await _readWithFallback(slug, locale, false);
711
+ return _hydrateRow(row);
712
+ },
713
+
714
+ // Operator + storefront list view. Returns one row per slug
715
+ // (the default-locale row when present). Filters by category +
716
+ // search + published_only. Cursor-paginated by (updated_at DESC,
717
+ // slug DESC) so a tampered cursor can't skip past rows the
718
+ // caller isn't supposed to see. `search` does a case-insensitive
719
+ // LIKE against title + body — for ranked suggestion use
720
+ // `searchSuggest`.
721
+ listArticles: async function (listOpts) {
722
+ listOpts = listOpts || {};
723
+ var limit = _limit(listOpts.limit, MAX_LIST_LIMIT, DEFAULT_LIST_LIMIT, "limit");
724
+ var cursorVals = _decodeCursor(listOpts.cursor, "listArticles");
725
+ var publishedOnly = listOpts.published_only === true;
726
+
727
+ var where = ["locale = ?1", "archived_at IS NULL"];
728
+ var params = [defaultLocale];
729
+ var idx = 2;
730
+
731
+ if (listOpts.category != null) {
732
+ var category = _category(listOpts.category);
733
+ where.push("category = ?" + idx);
734
+ params.push(category);
735
+ idx += 1;
736
+ }
737
+ if (publishedOnly) {
738
+ where.push("published = 1");
739
+ }
740
+ if (listOpts.search != null) {
741
+ var search = _queryStr(listOpts.search);
742
+ var like = "%" + search.toLowerCase().replace(/[%_\\]/g, function (m) { return "\\" + m; }) + "%";
743
+ where.push("(LOWER(title) LIKE ?" + idx + " ESCAPE '\\' OR LOWER(body) LIKE ?" + idx + " ESCAPE '\\')");
744
+ params.push(like);
745
+ idx += 1;
746
+ }
747
+ if (cursorVals) {
748
+ var a = idx;
749
+ var b = idx + 1;
750
+ where.push(
751
+ "(updated_at < ?" + a + " OR " +
752
+ "(updated_at = ?" + a + " AND slug < ?" + b + "))"
753
+ );
754
+ params.push(cursorVals[0], cursorVals[1]);
755
+ idx += 2;
756
+ }
757
+ params.push(limit);
758
+ var sql = "SELECT * FROM kb_articles WHERE " + where.join(" AND ") +
759
+ " ORDER BY updated_at DESC, slug DESC LIMIT ?" + idx;
760
+ var r = await query(sql, params);
761
+ var rows = r.rows.map(_hydrateRow);
762
+ return { rows: rows, next_cursor: _encodeNext(r.rows, limit) };
763
+ },
764
+
765
+ // Patch an existing article. The patch envelope can contain any
766
+ // subset of { title, body, locale, category, tags }. When
767
+ // `locale` is supplied, only that locale row is patched;
768
+ // otherwise the default-locale row is the target. category +
769
+ // tags are slug-wide — patching them rewrites every locale row
770
+ // for the slug.
771
+ updateArticle: async function (slug, patch) {
772
+ slug = _slug(slug);
773
+ if (!patch || typeof patch !== "object") {
774
+ throw new TypeError("knowledgeBase.updateArticle: patch object required");
775
+ }
776
+ var locale = patch.locale == null ? defaultLocale : _locale(patch.locale);
777
+ var existing = await _readExact(slug, locale);
778
+ if (!existing) {
779
+ var err = new Error("knowledgeBase.updateArticle: (slug='" + slug + "', locale='" + locale + "') not found");
780
+ err.code = "KB_ARTICLE_NOT_FOUND";
781
+ throw err;
782
+ }
783
+ if (existing.archived_at != null) {
784
+ var aErr = new Error("knowledgeBase.updateArticle: slug '" + slug + "' is archived");
785
+ aErr.code = "KB_ARTICLE_ARCHIVED";
786
+ throw aErr;
787
+ }
788
+ var ts = _now();
789
+ var title = patch.title == null ? existing.title : _title(patch.title);
790
+ var body = patch.body == null ? existing.body : _body(patch.body);
791
+
792
+ var rewriteSlugWide = patch.category != null || patch.tags !== undefined;
793
+ var category = patch.category == null
794
+ ? existing.category
795
+ : _category(patch.category);
796
+ var tagsJson;
797
+ if (patch.tags === undefined) {
798
+ tagsJson = existing.tags_json;
799
+ } else {
800
+ tagsJson = JSON.stringify(_tags(patch.tags));
801
+ }
802
+
803
+ await query(
804
+ "UPDATE kb_articles SET title = ?1, body = ?2, updated_at = ?3 " +
805
+ "WHERE slug = ?4 AND locale = ?5",
806
+ [title, body, ts, slug, locale],
807
+ );
808
+ if (rewriteSlugWide) {
809
+ await query(
810
+ "UPDATE kb_articles SET category = ?1, tags_json = ?2, updated_at = ?3 " +
811
+ "WHERE slug = ?4",
812
+ [category, tagsJson, ts, slug],
813
+ );
814
+ }
815
+ var fresh = await _readExact(slug, locale);
816
+ return _hydrateRow(fresh);
817
+ },
818
+
819
+ publishArticle: async function (slug) {
820
+ slug = _slug(slug);
821
+ var rows = await _readAllLocales(slug);
822
+ if (!rows.length) {
823
+ var err = new Error("knowledgeBase.publishArticle: slug '" + slug + "' not found");
824
+ err.code = "KB_ARTICLE_NOT_FOUND";
825
+ throw err;
826
+ }
827
+ var ts = _now();
828
+ await query(
829
+ "UPDATE kb_articles SET published = 1, archived_at = NULL, updated_at = ?1 WHERE slug = ?2",
830
+ [ts, slug],
831
+ );
832
+ var fresh = await _readExact(slug, defaultLocale);
833
+ if (!fresh) fresh = (await _readAllLocales(slug))[0];
834
+ return _hydrateRow(fresh);
835
+ },
836
+
837
+ unpublishArticle: async function (slug) {
838
+ slug = _slug(slug);
839
+ var rows = await _readAllLocales(slug);
840
+ if (!rows.length) {
841
+ var err = new Error("knowledgeBase.unpublishArticle: slug '" + slug + "' not found");
842
+ err.code = "KB_ARTICLE_NOT_FOUND";
843
+ throw err;
844
+ }
845
+ var ts = _now();
846
+ await query(
847
+ "UPDATE kb_articles SET published = 0, updated_at = ?1 WHERE slug = ?2",
848
+ [ts, slug],
849
+ );
850
+ var fresh = await _readExact(slug, defaultLocale);
851
+ if (!fresh) fresh = (await _readAllLocales(slug))[0];
852
+ return _hydrateRow(fresh);
853
+ },
854
+
855
+ archiveArticle: async function (slug) {
856
+ slug = _slug(slug);
857
+ var rows = await _readAllLocales(slug);
858
+ if (!rows.length) {
859
+ var err = new Error("knowledgeBase.archiveArticle: slug '" + slug + "' not found");
860
+ err.code = "KB_ARTICLE_NOT_FOUND";
861
+ throw err;
862
+ }
863
+ var ts = _now();
864
+ await query(
865
+ "UPDATE kb_articles SET archived_at = ?1, published = 0, updated_at = ?1 WHERE slug = ?2",
866
+ [ts, slug],
867
+ );
868
+ var fresh = await _readExact(slug, defaultLocale);
869
+ if (!fresh) fresh = (await _readAllLocales(slug))[0];
870
+ return _hydrateRow(fresh);
871
+ },
872
+
873
+ // Append-only view log. session_id is hashed at the door; the
874
+ // raw value never reaches storage. Bumps the slug-wide
875
+ // view_count on every locale row so popularArticles sorts
876
+ // by combined-locale traffic.
877
+ recordView: async function (input) {
878
+ if (!input || typeof input !== "object") {
879
+ throw new TypeError("knowledgeBase.recordView: input object required");
880
+ }
881
+ var slug = _slug(input.slug);
882
+ var rows = await _readAllLocales(slug);
883
+ if (!rows.length) {
884
+ var err = new Error("knowledgeBase.recordView: slug '" + slug + "' not found");
885
+ err.code = "KB_ARTICLE_NOT_FOUND";
886
+ throw err;
887
+ }
888
+ var sessionHash = null;
889
+ if (input.session_id != null) {
890
+ sessionHash = _hashSession(VIEW_NAMESPACE, _sessionIdRaw(input.session_id));
891
+ }
892
+ var customerId = null;
893
+ if (input.customer_id != null) {
894
+ customerId = _customerId(input.customer_id);
895
+ }
896
+ var ts = _now();
897
+ await query(
898
+ "INSERT INTO kb_views (id, slug, session_id_hash, customer_id, occurred_at) " +
899
+ "VALUES (?1, ?2, ?3, ?4, ?5)",
900
+ [_b().uuid.v7(), slug, sessionHash, customerId, ts],
901
+ );
902
+ await query(
903
+ "UPDATE kb_articles SET view_count = view_count + 1 WHERE slug = ?1",
904
+ [slug],
905
+ );
906
+ return { slug: slug, occurred_at: ts };
907
+ },
908
+
909
+ // Vote: helpful / not_helpful. Deduped at (slug, session_id_hash)
910
+ // via the UNIQUE constraint — a repeat vote from the same session
911
+ // collapses to a no-op. session_id is required (votes without a
912
+ // session are anonymous spam in disguise — the FAQ reading flow
913
+ // already has a session).
914
+ recordVote: async function (input) {
915
+ if (!input || typeof input !== "object") {
916
+ throw new TypeError("knowledgeBase.recordVote: input object required");
917
+ }
918
+ var slug = _slug(input.slug);
919
+ var session = _sessionIdRaw(input.session_id);
920
+ var vote = _vote(input.vote);
921
+ var rows = await _readAllLocales(slug);
922
+ if (!rows.length) {
923
+ var err = new Error("knowledgeBase.recordVote: slug '" + slug + "' not found");
924
+ err.code = "KB_ARTICLE_NOT_FOUND";
925
+ throw err;
926
+ }
927
+ var sessionHash = _hashSession(VOTE_NAMESPACE, session);
928
+ var ts = _now();
929
+
930
+ var inserted = await query(
931
+ "INSERT OR IGNORE INTO kb_votes (id, slug, session_id_hash, vote, occurred_at) " +
932
+ "VALUES (?1, ?2, ?3, ?4, ?5)",
933
+ [_b().uuid.v7(), slug, sessionHash, vote, ts],
934
+ );
935
+ var changed = inserted && (inserted.rowCount === 1 || inserted.changes === 1);
936
+ if (changed) {
937
+ var col = vote === "helpful" ? "helpful_count" : "not_helpful_count";
938
+ await query(
939
+ "UPDATE kb_articles SET " + col + " = " + col + " + 1 WHERE slug = ?1",
940
+ [slug],
941
+ );
942
+ }
943
+ return { slug: slug, vote: vote, recorded: !!changed };
944
+ },
945
+
946
+ voteAggregateForArticle: async function (slug) {
947
+ slug = _slug(slug);
948
+ var rows = await _readAllLocales(slug);
949
+ if (!rows.length) {
950
+ var err = new Error("knowledgeBase.voteAggregateForArticle: slug '" + slug + "' not found");
951
+ err.code = "KB_ARTICLE_NOT_FOUND";
952
+ throw err;
953
+ }
954
+ var r = rows[0];
955
+ var helpful = Number(r.helpful_count) || 0;
956
+ var notHelpful = Number(r.not_helpful_count) || 0;
957
+ var total = helpful + notHelpful;
958
+ return {
959
+ slug: slug,
960
+ helpful_count: helpful,
961
+ not_helpful_count: notHelpful,
962
+ total_votes: total,
963
+ helpfulness_ratio: total > 0 ? helpful / total : null,
964
+ };
965
+ },
966
+
967
+ // Top-N most-viewed articles over a closed time window. Views
968
+ // are counted from kb_views.occurred_at against [from, to].
969
+ // Archived + unpublished articles are filtered. Sorted by view
970
+ // count DESC, slug DESC.
971
+ popularArticles: async function (input) {
972
+ if (!input || typeof input !== "object") {
973
+ throw new TypeError("knowledgeBase.popularArticles: input object required");
974
+ }
975
+ var from = input.from;
976
+ var to = input.to;
977
+ _timestampRange(from, to, "popularArticles");
978
+ var limit = _limit(input.limit, MAX_POPULAR_LIMIT, DEFAULT_POPULAR_LIMIT, "limit");
979
+
980
+ var sql =
981
+ "SELECT v.slug AS slug, COUNT(*) AS views " +
982
+ "FROM kb_views v " +
983
+ "JOIN kb_articles a ON a.slug = v.slug AND a.locale = ?1 " +
984
+ "WHERE v.occurred_at >= ?2 AND v.occurred_at <= ?3 " +
985
+ " AND a.archived_at IS NULL AND a.published = 1 " +
986
+ "GROUP BY v.slug " +
987
+ "ORDER BY views DESC, v.slug DESC " +
988
+ "LIMIT ?4";
989
+ var r = await query(sql, [defaultLocale, from, to, limit]);
990
+ var out = [];
991
+ for (var i = 0; i < r.rows.length; i += 1) {
992
+ var row = r.rows[i];
993
+ out.push({ slug: row.slug, views: Number(row.views) });
994
+ }
995
+ return out;
996
+ },
997
+
998
+ // Ranked search-suggest. Tokenizes the query, then scans every
999
+ // published + non-archived article in the requested locale
1000
+ // (with default-locale fallback) and computes a weighted score:
1001
+ // title-token match (weight 3) + tag-token match (weight 2) +
1002
+ // body-token match (weight 1). Articles with score 0 are
1003
+ // excluded; ties break on slug DESC (deterministic).
1004
+ //
1005
+ // The implementation reads every candidate row in JS rather
1006
+ // than pushing the ranker into SQL — the corpus is FAQ-sized
1007
+ // (dozens to hundreds of articles) and the JS path keeps the
1008
+ // primitive in the zero-runtime-deps envelope. Operators with
1009
+ // very large knowledge bases can pre-filter via `category` to
1010
+ // bound the candidate set.
1011
+ searchSuggest: async function (input) {
1012
+ if (!input || typeof input !== "object") {
1013
+ throw new TypeError("knowledgeBase.searchSuggest: input object required");
1014
+ }
1015
+ var queryString = _queryStr(input.query);
1016
+ var tokens = _tokenize(queryString);
1017
+ var limit = _limit(input.limit, MAX_SUGGEST_LIMIT, DEFAULT_SUGGEST_LIMIT, "limit");
1018
+ var locale = input.locale == null ? defaultLocale : _locale(input.locale);
1019
+ var category;
1020
+ if (input.category != null) {
1021
+ category = _category(input.category);
1022
+ }
1023
+
1024
+ if (!tokens.length) return [];
1025
+
1026
+ var sql, params;
1027
+ if (category) {
1028
+ sql = "SELECT * FROM kb_articles WHERE archived_at IS NULL AND published = 1 AND category = ?1";
1029
+ params = [category];
1030
+ } else {
1031
+ sql = "SELECT * FROM kb_articles WHERE archived_at IS NULL AND published = 1";
1032
+ params = [];
1033
+ }
1034
+ var r = await query(sql, params);
1035
+
1036
+ var bySlug = {};
1037
+ var chain = _fallbackChain(locale, defaultLocale);
1038
+ for (var i = 0; i < r.rows.length; i += 1) {
1039
+ var row = r.rows[i];
1040
+ var pri = chain.indexOf(row.locale);
1041
+ if (pri === -1) continue;
1042
+ var current = bySlug[row.slug];
1043
+ if (!current || current._chainIdx > pri) {
1044
+ row._chainIdx = pri;
1045
+ bySlug[row.slug] = row;
1046
+ }
1047
+ }
1048
+
1049
+ var ranked = [];
1050
+ var slugs = Object.keys(bySlug);
1051
+ for (var s = 0; s < slugs.length; s += 1) {
1052
+ var rowS = bySlug[slugs[s]];
1053
+ var titleTokens = _tokenize(rowS.title);
1054
+ var bodyTokens = _tokenize(rowS.body);
1055
+ var tagList;
1056
+ try { tagList = JSON.parse(rowS.tags_json || "[]"); }
1057
+ catch (_e) { tagList = []; }
1058
+
1059
+ var score = 0;
1060
+ for (var t = 0; t < tokens.length; t += 1) {
1061
+ var token = tokens[t];
1062
+ if (titleTokens.indexOf(token) !== -1) score += SEARCH_WEIGHTS.title;
1063
+ if (tagList.indexOf(token) !== -1) score += SEARCH_WEIGHTS.tag;
1064
+ if (bodyTokens.indexOf(token) !== -1) score += SEARCH_WEIGHTS.body;
1065
+ }
1066
+ if (score > 0) {
1067
+ ranked.push({
1068
+ slug: rowS.slug,
1069
+ locale: rowS.locale,
1070
+ title: rowS.title,
1071
+ category: rowS.category,
1072
+ score: score,
1073
+ });
1074
+ }
1075
+ }
1076
+
1077
+ ranked.sort(function (a, b) {
1078
+ if (b.score !== a.score) return b.score - a.score;
1079
+ if (a.slug < b.slug) return 1;
1080
+ if (a.slug > b.slug) return -1;
1081
+ return 0;
1082
+ });
1083
+
1084
+ return ranked.slice(0, limit);
1085
+ },
1086
+ };
1087
+ }
1088
+
1089
+ module.exports = {
1090
+ create: create,
1091
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
1092
+ MAX_TITLE_LEN: MAX_TITLE_LEN,
1093
+ MAX_BODY_LEN: MAX_BODY_LEN,
1094
+ MAX_CATEGORY_LEN: MAX_CATEGORY_LEN,
1095
+ MAX_TAG_LEN: MAX_TAG_LEN,
1096
+ MAX_TAG_COUNT: MAX_TAG_COUNT,
1097
+ MAX_LOCALE_LEN: MAX_LOCALE_LEN,
1098
+ MAX_QUERY_LEN: MAX_QUERY_LEN,
1099
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
1100
+ MAX_POPULAR_LIMIT: MAX_POPULAR_LIMIT,
1101
+ MAX_SUGGEST_LIMIT: MAX_SUGGEST_LIMIT,
1102
+ ALLOWED_VOTES: ALLOWED_VOTES.slice(),
1103
+ SEARCH_WEIGHTS: Object.assign({}, SEARCH_WEIGHTS),
1104
+ };