@blamejs/blamejs-shop 0.0.72 → 0.0.75

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 (44) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/lib/announcement-bar.js +753 -0
  3. package/lib/banner-ab-tests.js +806 -0
  4. package/lib/bin-locations.js +791 -0
  5. package/lib/blog-articles.js +1173 -0
  6. package/lib/carrier-accounts.js +805 -0
  7. package/lib/cart-recovery.js +1133 -0
  8. package/lib/category-navigation.js +934 -0
  9. package/lib/consent-ledger.js +539 -0
  10. package/lib/customer-impersonation.js +743 -0
  11. package/lib/customer-merge.js +879 -0
  12. package/lib/demand-forecast.js +1121 -0
  13. package/lib/dispute-resolution.js +886 -0
  14. package/lib/email-ab-tests.js +918 -0
  15. package/lib/email-engagement-score.js +649 -0
  16. package/lib/event-log.js +713 -0
  17. package/lib/fulfillment-sla.js +791 -0
  18. package/lib/index.js +41 -0
  19. package/lib/inventory-audits.js +852 -0
  20. package/lib/line-gift-wrap.js +430 -0
  21. package/lib/marketing-budget.js +792 -0
  22. package/lib/operator-activity-feed.js +977 -0
  23. package/lib/operator-approvals.js +942 -0
  24. package/lib/operator-help-center.js +1020 -0
  25. package/lib/operator-inbox.js +889 -0
  26. package/lib/operator-sessions.js +701 -0
  27. package/lib/order-exchanges.js +602 -0
  28. package/lib/product-compare.js +804 -0
  29. package/lib/pwa-manifest.js +1005 -0
  30. package/lib/referral-leaderboard.js +612 -0
  31. package/lib/sales-tax-filings.js +807 -0
  32. package/lib/search-ranking.js +859 -0
  33. package/lib/shipping-insurance.js +757 -0
  34. package/lib/shrinkage-report.js +1182 -0
  35. package/lib/sidebar-widgets.js +952 -0
  36. package/lib/smart-restocking.js +1048 -0
  37. package/lib/stock-receipts.js +834 -0
  38. package/lib/subscription-analytics.js +1032 -0
  39. package/lib/suggestion-box.js +921 -0
  40. package/lib/tax-remittance.js +625 -0
  41. package/lib/vendor-invoices.js +1021 -0
  42. package/lib/winback-campaigns.js +1350 -0
  43. package/lib/wishlist-digest.js +1133 -0
  44. package/package.json +1 -1
@@ -0,0 +1,1173 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.blogArticles
4
+ * @title Blog articles — operator-published editorial storefront content
5
+ *
6
+ * @intro
7
+ * Editorial blog separate from `storefrontPages` (legal / about /
8
+ * shipping — the long-tail of fixed slots every shop carries). The
9
+ * blog is cadence-driven: posts shipped over time, attributed to an
10
+ * author, tagged for browsability, linked back to the catalog so a
11
+ * "five-mistakes-buying-X" post can surface the matching SKUs at
12
+ * the bottom of the article.
13
+ *
14
+ * Each article carries:
15
+ * - a stable URL-friendly `slug` (the PK; appears in /blog/<slug>),
16
+ * - a `title` shown in the post header and the `<title>` tag,
17
+ * - a `body` authored in the in-process Markdown subset shared
18
+ * with cmsBlocks / knowledgeBase / storefrontPages,
19
+ * - an `author_id` referencing the operator's authors directory
20
+ * (this primitive does NOT own the author entity — author rows
21
+ * live wherever the operator chose to model the editorial team),
22
+ * - `tags` (free-form string array) for the "more like this" rail
23
+ * and the related-articles ranker,
24
+ * - `featured_product_ids` linking back to the catalog,
25
+ * - an optional `hero_image_url` (https-only, gated by
26
+ * `b.safeUrl.parse`),
27
+ * - `meta_description` + `meta_keywords` for the article's
28
+ * `<head>` SEO surface,
29
+ * - a `status` FSM (draft -> published -> archived) with a restore
30
+ * path back to draft.
31
+ *
32
+ * The publish FSM:
33
+ *
34
+ * draft --publish-- published
35
+ * published --unpublish-- draft
36
+ * published --archive--- archived
37
+ * archived --restore--- draft
38
+ *
39
+ * Every other transition is refused with an error that names the
40
+ * current state and the attempted action so an operator running a
41
+ * publish sweep can see exactly which post is wedged.
42
+ *
43
+ * Composes:
44
+ * - `b.template.escapeHtml` — every text run from the operator-
45
+ * authored body lands as HTML-escaped output in `renderHtml`.
46
+ * - `b.safeUrl.parse` — `hero_image_url` is https-only; inline
47
+ * `[text](url)` link URLs pass the https-only allowlist (or
48
+ * `/`-rooted absolute paths). A link carrying `javascript:` /
49
+ * `data:` / protocol-relative `//host` is dropped from the
50
+ * rendered HTML and the anchor text falls back to inert escaped
51
+ * text.
52
+ * - `b.crypto.namespaceHash` — session-id hashing on `recordView`.
53
+ * - `b.uuid.v7` — row ids on view-log rows.
54
+ * - `b.pagination` — HMAC-tagged tuple cursor for `listPublished`.
55
+ *
56
+ * Monotonic per-process clock: two writes in the same millisecond
57
+ * would tie on `published_at` / `updated_at` and make a sort-by-
58
+ * timestamp read ambiguous. `_now` bumps to `prior + 1` on collision
59
+ * so the (published_at DESC, slug DESC) listPublished cursor +
60
+ * popularArticles aggregation carry a strict per-process ordering.
61
+ *
62
+ * Surface:
63
+ * - `create({ query?, customers? })` — factory. `query` is
64
+ * optional; absent it the primitive talks to `b.externalDb.query`
65
+ * directly. `customers` is reserved for future cross-primitive
66
+ * composition (e.g. byline -> customer-of-record lookups).
67
+ * - `createDraft({ slug, title, body, author_id, tags?,
68
+ * featured_product_ids?, hero_image_url?,
69
+ * meta_description?, meta_keywords? })` — insert
70
+ * a row in `draft` status.
71
+ * - `publish(slug)` / `unpublish(slug)` / `archive(slug)` /
72
+ * `restore(slug)` — FSM transitions.
73
+ * - `update(slug, patch)` — patch any of the editable columns
74
+ * (title / body / author_id / tags / featured_product_ids /
75
+ * hero_image_url / meta_description / meta_keywords).
76
+ * - `get(slug)` — any status. `getPublished(slug)` — published
77
+ * only (returns null for any other state).
78
+ * - `listPublished({ tag?, cursor?, limit? })` — newest-published-
79
+ * first cursor-paginated.
80
+ * - `listDrafts()` / `listArchived()` — enumerate by FSM state.
81
+ * - `renderHtml({ slug })` — async; reads the post by slug and
82
+ * returns sanitized HTML from the Markdown body. Missing slug
83
+ * throws (renderHtml is config-tier: a missing slug is an
84
+ * operator bug).
85
+ * - `relatedArticles({ slug, limit? })` — top-N published posts
86
+ * ranked by tag-overlap with the requested slug (the requested
87
+ * slug itself is excluded; archived / unpublished excluded).
88
+ * - `byAuthor({ author_id, status?, limit? })` — every article
89
+ * attributed to an author.
90
+ * - `recordView({ slug, session_id? })` — append-only view log;
91
+ * bumps slug-wide view_count.
92
+ * - `popularArticles({ from, to, limit? })` — top-N over a closed
93
+ * time window.
94
+ *
95
+ * Storage:
96
+ * - `blog_articles`, `blog_article_views` (migration
97
+ * `0189_blog_articles.sql`).
98
+ *
99
+ * @primitive blogArticles
100
+ * @related b.template.escapeHtml, b.safeUrl, b.crypto.namespaceHash,
101
+ * b.uuid.v7, b.pagination, shop.storefrontPages,
102
+ * shop.knowledgeBase, shop.cmsBlocks
103
+ */
104
+
105
+ var MAX_SLUG_LEN = 120;
106
+ var MAX_TITLE_LEN = 200;
107
+ var MAX_BODY_LEN = 200000;
108
+ var MAX_AUTHOR_ID_LEN = 80;
109
+ var MAX_TAG_LEN = 40;
110
+ var MAX_TAG_COUNT = 24;
111
+ var MAX_FEATURED_PRODUCT_LEN = 80;
112
+ var MAX_FEATURED_PRODUCT_COUNT = 24;
113
+ var MAX_HERO_IMAGE_URL_LEN = 2048;
114
+ var MAX_META_DESCRIPTION_LEN = 320;
115
+ var MAX_META_KEYWORDS_LEN = 320;
116
+ var MAX_LIST_LIMIT = 100;
117
+ var DEFAULT_LIST_LIMIT = 25;
118
+ var MAX_RELATED_LIMIT = 50;
119
+ var DEFAULT_RELATED_LIMIT = 5;
120
+ var MAX_POPULAR_LIMIT = 100;
121
+ var DEFAULT_POPULAR_LIMIT = 10;
122
+ var MAX_BY_AUTHOR_LIMIT = 200;
123
+ var DEFAULT_BY_AUTHOR_LIMIT = 50;
124
+
125
+ var ALLOWED_STATUSES = Object.freeze([
126
+ "draft",
127
+ "published",
128
+ "archived",
129
+ ]);
130
+
131
+ var ALLOWED_PATCH_COLUMNS = Object.freeze([
132
+ "title",
133
+ "body",
134
+ "author_id",
135
+ "tags",
136
+ "featured_product_ids",
137
+ "hero_image_url",
138
+ "meta_description",
139
+ "meta_keywords",
140
+ ]);
141
+
142
+ var VIEW_NAMESPACE = "blog-article-view-session";
143
+
144
+ // listPublished cursor order: newest-published-first, slug as the
145
+ // stable tiebreaker so two posts published in the same millisecond
146
+ // still order deterministically.
147
+ var LIST_ORDER_KEY = ["published_at:desc", "slug:desc"];
148
+
149
+ // Slug shape mirrors knowledgeBase: lowercase alnum + dash, no
150
+ // leading/trailing dash, capped length. The slug reaches operator
151
+ // logs + the public storefront URL `/blog/<slug>`.
152
+ var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
153
+ var TAG_RE = /^[a-z0-9][a-z0-9_-]*$/;
154
+ var AUTHOR_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
155
+ var PRODUCT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
156
+
157
+ // Refuse C0 control bytes + DEL in operator-authored strings. The
158
+ // body permits LF (Markdown is line-oriented); single-line fields
159
+ // refuse LF / CR so they can't smuggle a second line into a post
160
+ // header / meta tag / hero-image attribute.
161
+ var CONTROL_BYTE_LINE_RE = /[\x00-\x1f\x7f]/;
162
+ var CONTROL_BYTE_BLOCK_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
163
+
164
+ // Zero-width / direction-override family — spelled with \u-escapes so
165
+ // ESLint's no-irregular-whitespace stays happy.
166
+ var ZERO_WIDTH_RE = new RegExp(
167
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
168
+ );
169
+
170
+ var bShop;
171
+ function _b() {
172
+ if (!bShop) bShop = require("./index");
173
+ return bShop.framework;
174
+ }
175
+
176
+ // ---- monotonic clock ---------------------------------------------------
177
+ //
178
+ // Operator-driven writes can land in the same millisecond on fast
179
+ // machines. Bumping by 1ms on a tie keeps the timeline strictly
180
+ // increasing so the (published_at DESC, slug DESC) listPublished
181
+ // cursor + popularArticles aggregation return events in the order
182
+ // they were issued.
183
+
184
+ var _lastTs = 0;
185
+ function _now() {
186
+ var t = Date.now();
187
+ if (t <= _lastTs) t = _lastTs + 1;
188
+ _lastTs = t;
189
+ return t;
190
+ }
191
+
192
+ // ---- validators --------------------------------------------------------
193
+
194
+ function _slug(s) {
195
+ if (typeof s !== "string" || !s.length) {
196
+ throw new TypeError("blogArticles: slug must be a non-empty string");
197
+ }
198
+ if (s.length > MAX_SLUG_LEN) {
199
+ throw new TypeError("blogArticles: slug must be <= " + MAX_SLUG_LEN + " characters");
200
+ }
201
+ if (!SLUG_RE.test(s)) {
202
+ throw new TypeError("blogArticles: slug must match /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/");
203
+ }
204
+ return s;
205
+ }
206
+
207
+ function _title(s) {
208
+ if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
209
+ throw new TypeError("blogArticles: title must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
210
+ }
211
+ if (CONTROL_BYTE_LINE_RE.test(s)) {
212
+ throw new TypeError("blogArticles: title contains control bytes (incl. CR/LF)");
213
+ }
214
+ if (ZERO_WIDTH_RE.test(s)) {
215
+ throw new TypeError("blogArticles: title contains zero-width / direction-override characters");
216
+ }
217
+ return s;
218
+ }
219
+
220
+ function _body(s) {
221
+ if (typeof s !== "string" || !s.length || s.length > MAX_BODY_LEN) {
222
+ throw new TypeError("blogArticles: body must be a non-empty string <= " + MAX_BODY_LEN + " chars");
223
+ }
224
+ if (CONTROL_BYTE_BLOCK_RE.test(s)) {
225
+ throw new TypeError("blogArticles: body contains control bytes");
226
+ }
227
+ if (ZERO_WIDTH_RE.test(s)) {
228
+ throw new TypeError("blogArticles: body contains zero-width / direction-override characters");
229
+ }
230
+ return s;
231
+ }
232
+
233
+ function _authorId(s) {
234
+ if (typeof s !== "string" || !s.length || s.length > MAX_AUTHOR_ID_LEN) {
235
+ throw new TypeError("blogArticles: author_id must be a non-empty string <= " + MAX_AUTHOR_ID_LEN + " chars");
236
+ }
237
+ if (!AUTHOR_ID_RE.test(s)) {
238
+ throw new TypeError("blogArticles: author_id must match /^[A-Za-z0-9][A-Za-z0-9._:-]*$/");
239
+ }
240
+ return s;
241
+ }
242
+
243
+ function _tagList(input, label) {
244
+ label = label || "tags";
245
+ if (input == null) return [];
246
+ if (!Array.isArray(input)) {
247
+ throw new TypeError("blogArticles: " + label + " must be an array of strings");
248
+ }
249
+ if (input.length > MAX_TAG_COUNT) {
250
+ throw new TypeError("blogArticles: " + label + " must contain <= " + MAX_TAG_COUNT + " entries");
251
+ }
252
+ var seen = Object.create(null);
253
+ var out = [];
254
+ for (var i = 0; i < input.length; i += 1) {
255
+ var t = input[i];
256
+ if (typeof t !== "string" || !t.length) {
257
+ throw new TypeError("blogArticles: " + label + "[" + i + "] must be a non-empty string");
258
+ }
259
+ if (t.length > MAX_TAG_LEN) {
260
+ throw new TypeError("blogArticles: " + label + "[" + i + "] must be <= " + MAX_TAG_LEN + " characters");
261
+ }
262
+ if (!TAG_RE.test(t)) {
263
+ throw new TypeError("blogArticles: " + label + "[" + i + "] must match /^[a-z0-9][a-z0-9_-]*$/");
264
+ }
265
+ if (seen[t]) continue;
266
+ seen[t] = 1;
267
+ out.push(t);
268
+ }
269
+ return out;
270
+ }
271
+
272
+ function _featuredProductIds(input) {
273
+ if (input == null) return [];
274
+ if (!Array.isArray(input)) {
275
+ throw new TypeError("blogArticles: featured_product_ids must be an array of strings");
276
+ }
277
+ if (input.length > MAX_FEATURED_PRODUCT_COUNT) {
278
+ throw new TypeError("blogArticles: featured_product_ids must contain <= " + MAX_FEATURED_PRODUCT_COUNT + " entries");
279
+ }
280
+ var seen = Object.create(null);
281
+ var out = [];
282
+ for (var i = 0; i < input.length; i += 1) {
283
+ var p = input[i];
284
+ if (typeof p !== "string" || !p.length) {
285
+ throw new TypeError("blogArticles: featured_product_ids[" + i + "] must be a non-empty string");
286
+ }
287
+ if (p.length > MAX_FEATURED_PRODUCT_LEN) {
288
+ throw new TypeError("blogArticles: featured_product_ids[" + i + "] must be <= " + MAX_FEATURED_PRODUCT_LEN + " characters");
289
+ }
290
+ if (!PRODUCT_ID_RE.test(p)) {
291
+ throw new TypeError("blogArticles: featured_product_ids[" + i + "] must match /^[A-Za-z0-9][A-Za-z0-9._:-]*$/");
292
+ }
293
+ if (seen[p]) continue;
294
+ seen[p] = 1;
295
+ out.push(p);
296
+ }
297
+ return out;
298
+ }
299
+
300
+ // hero_image_url passes the same https-only / `/`-rooted-absolute
301
+ // gate as announcementBar's link_url. Protocol-relative
302
+ // `//host/img.png` is refused so a CDN mis-config can't downgrade
303
+ // the asset. `javascript:` / `data:` / `vbscript:` are refused by
304
+ // safeUrl's default protocol allowlist.
305
+ function _heroImageUrl(s) {
306
+ if (s == null) return null;
307
+ if (typeof s !== "string" || !s.length) {
308
+ throw new TypeError("blogArticles: hero_image_url must be a non-empty string");
309
+ }
310
+ if (s.length > MAX_HERO_IMAGE_URL_LEN) {
311
+ throw new TypeError("blogArticles: hero_image_url must be <= " + MAX_HERO_IMAGE_URL_LEN + " chars");
312
+ }
313
+ if (CONTROL_BYTE_LINE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
314
+ throw new TypeError("blogArticles: hero_image_url contains control / zero-width bytes");
315
+ }
316
+ if (s.charCodeAt(0) === 47 /* "/" */) {
317
+ if (s.length > 1 && s.charCodeAt(1) === 47) {
318
+ throw new TypeError("blogArticles: hero_image_url protocol-relative `//host/...` refused — use absolute https://");
319
+ }
320
+ if (s.indexOf("..") !== -1) {
321
+ throw new TypeError("blogArticles: hero_image_url path must not contain '..'");
322
+ }
323
+ return s;
324
+ }
325
+ try {
326
+ _b().safeUrl.parse(s, { allowedProtocols: ["https:"] });
327
+ } catch (e) {
328
+ throw new TypeError("blogArticles: hero_image_url — " + (e && e.message || "must be https:// or a /-rooted absolute path"));
329
+ }
330
+ return s;
331
+ }
332
+
333
+ function _metaLine(s, label, maxLen) {
334
+ if (s == null) return null;
335
+ if (typeof s !== "string" || s.length > maxLen) {
336
+ throw new TypeError("blogArticles: " + label + " must be a string <= " + maxLen + " chars");
337
+ }
338
+ if (CONTROL_BYTE_LINE_RE.test(s)) {
339
+ throw new TypeError("blogArticles: " + label + " contains control bytes (incl. CR/LF)");
340
+ }
341
+ if (ZERO_WIDTH_RE.test(s)) {
342
+ throw new TypeError("blogArticles: " + label + " contains zero-width / direction-override characters");
343
+ }
344
+ return s;
345
+ }
346
+
347
+ function _limit(n, max, def, label) {
348
+ if (n == null) return def;
349
+ if (!Number.isInteger(n) || n <= 0 || n > max) {
350
+ throw new TypeError("blogArticles: " + label + " must be an integer 1..." + max);
351
+ }
352
+ return n;
353
+ }
354
+
355
+ function _timestampRange(from, to, label) {
356
+ if (!Number.isInteger(from) || from < 0) {
357
+ throw new TypeError("blogArticles." + label + ": from must be a non-negative integer (ms epoch)");
358
+ }
359
+ if (!Number.isInteger(to) || to < 0) {
360
+ throw new TypeError("blogArticles." + label + ": to must be a non-negative integer (ms epoch)");
361
+ }
362
+ if (from > to) {
363
+ throw new TypeError("blogArticles." + label + ": from must be <= to");
364
+ }
365
+ }
366
+
367
+ function _sessionIdRaw(s) {
368
+ if (typeof s !== "string" || !s.length) {
369
+ throw new TypeError("blogArticles: session_id must be a non-empty string");
370
+ }
371
+ if (s.length > 256) {
372
+ throw new TypeError("blogArticles: session_id must be <= 256 characters");
373
+ }
374
+ if (CONTROL_BYTE_LINE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
375
+ throw new TypeError("blogArticles: session_id contains control / zero-width bytes");
376
+ }
377
+ return s;
378
+ }
379
+
380
+ // ---- row hydration -----------------------------------------------------
381
+
382
+ function _safeJsonArray(s) {
383
+ if (s == null) return [];
384
+ try {
385
+ var parsed = JSON.parse(s);
386
+ return Array.isArray(parsed) ? parsed : [];
387
+ } catch (_e) {
388
+ return [];
389
+ }
390
+ }
391
+
392
+ function _hydrateRow(r) {
393
+ if (!r) return null;
394
+ return {
395
+ slug: r.slug,
396
+ title: r.title,
397
+ body: r.body,
398
+ author_id: r.author_id,
399
+ tags: _safeJsonArray(r.tags_json),
400
+ featured_product_ids: _safeJsonArray(r.featured_product_ids_json),
401
+ hero_image_url: r.hero_image_url == null ? null : r.hero_image_url,
402
+ meta_description: r.meta_description == null ? null : r.meta_description,
403
+ meta_keywords: r.meta_keywords == null ? null : r.meta_keywords,
404
+ status: r.status,
405
+ published_at: r.published_at == null ? null : Number(r.published_at),
406
+ archived_at: r.archived_at == null ? null : Number(r.archived_at),
407
+ view_count: Number(r.view_count) || 0,
408
+ created_at: Number(r.created_at),
409
+ updated_at: Number(r.updated_at),
410
+ };
411
+ }
412
+
413
+ // ---- Markdown to HTML --------------------------------------------------
414
+ //
415
+ // Minimal in-process Markdown subset shared with cmsBlocks /
416
+ // knowledgeBase / storefrontPages. Every text run is HTML-escaped via
417
+ // `b.template.escapeHtml`; every link URL passes through
418
+ // `b.safeUrl.parse` (https-only) OR an allow-list for `/`-rooted
419
+ // absolute paths. Any URL that fails the gate is dropped from the
420
+ // rendered HTML; the anchor text falls back to inert escaped text.
421
+ // Raw HTML in the body is never passed through — any `<` lands as
422
+ // `&lt;`. That's the whole defense against XSS: every operator-input
423
+ // byte is HTML-escaped before it reaches the `<article>` of the page.
424
+
425
+ function _esc(s) {
426
+ return _b().template.escapeHtml(s);
427
+ }
428
+
429
+ function _safeLinkUrl(url) {
430
+ if (typeof url !== "string" || !url.length || url.length > 2048) return null;
431
+ if (CONTROL_BYTE_LINE_RE.test(url) || ZERO_WIDTH_RE.test(url)) return null;
432
+ if (url.charCodeAt(0) === 47 /* "/" */) {
433
+ if (url.length > 1 && url.charCodeAt(1) === 47) return null;
434
+ if (url.indexOf("..") !== -1) return null;
435
+ return url;
436
+ }
437
+ try {
438
+ _b().safeUrl.parse(url, { allowedProtocols: ["https:"] });
439
+ } catch (_e) {
440
+ return null;
441
+ }
442
+ return url;
443
+ }
444
+
445
+ function _renderInline(line) {
446
+ var out = "";
447
+ var i = 0;
448
+ while (i < line.length) {
449
+ var ch = line.charAt(i);
450
+ if (ch === "`") {
451
+ var end = line.indexOf("`", i + 1);
452
+ if (end !== -1) {
453
+ out += "<code>" + _esc(line.slice(i + 1, end)) + "</code>";
454
+ i = end + 1;
455
+ continue;
456
+ }
457
+ }
458
+ if (ch === "[") {
459
+ var closeBracket = line.indexOf("]", i + 1);
460
+ if (closeBracket !== -1 && line.charAt(closeBracket + 1) === "(") {
461
+ var closeParen = line.indexOf(")", closeBracket + 2);
462
+ if (closeParen !== -1) {
463
+ var text = line.slice(i + 1, closeBracket);
464
+ var url = line.slice(closeBracket + 2, closeParen);
465
+ var safe = _safeLinkUrl(url);
466
+ if (safe) {
467
+ out += '<a href="' + _esc(safe) + '">' + _renderInline(text) + "</a>";
468
+ } else {
469
+ out += _renderInline(text);
470
+ }
471
+ i = closeParen + 1;
472
+ continue;
473
+ }
474
+ }
475
+ }
476
+ if (ch === "*" && line.charAt(i + 1) === "*") {
477
+ var endBold = line.indexOf("**", i + 2);
478
+ if (endBold !== -1) {
479
+ out += "<strong>" + _renderInline(line.slice(i + 2, endBold)) + "</strong>";
480
+ i = endBold + 2;
481
+ continue;
482
+ }
483
+ }
484
+ if (ch === "*" || ch === "_") {
485
+ var endItalic = line.indexOf(ch, i + 1);
486
+ if (endItalic !== -1 && endItalic !== i + 1) {
487
+ out += "<em>" + _renderInline(line.slice(i + 1, endItalic)) + "</em>";
488
+ i = endItalic + 1;
489
+ continue;
490
+ }
491
+ }
492
+ out += _esc(ch);
493
+ i += 1;
494
+ }
495
+ return out;
496
+ }
497
+
498
+ function _renderMarkdown(body) {
499
+ var normalized = String(body).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
500
+ var lines = normalized.split("\n");
501
+ var out = [];
502
+ var i = 0;
503
+ while (i < lines.length) {
504
+ var line = lines[i];
505
+ if (line.trim() === "") { i += 1; continue; }
506
+ if (/^-{3,}\s*$/.test(line)) {
507
+ out.push("<hr />");
508
+ i += 1;
509
+ continue;
510
+ }
511
+ var hMatch = /^(#{1,6})\s+(.*)$/.exec(line);
512
+ if (hMatch) {
513
+ var level = hMatch[1].length;
514
+ out.push("<h" + level + ">" + _renderInline(hMatch[2].trim()) + "</h" + level + ">");
515
+ i += 1;
516
+ continue;
517
+ }
518
+ if (/^>\s?/.test(line)) {
519
+ var quoteLines = [];
520
+ while (i < lines.length && /^>\s?/.test(lines[i])) {
521
+ quoteLines.push(lines[i].replace(/^>\s?/, ""));
522
+ i += 1;
523
+ }
524
+ out.push("<blockquote><p>" + _renderInline(quoteLines.join(" ")) + "</p></blockquote>");
525
+ continue;
526
+ }
527
+ if (/^[-*]\s+/.test(line)) {
528
+ var ulItems = [];
529
+ while (i < lines.length && /^[-*]\s+/.test(lines[i])) {
530
+ ulItems.push(lines[i].replace(/^[-*]\s+/, ""));
531
+ i += 1;
532
+ }
533
+ var ulHtml = ulItems.map(function (item) {
534
+ return "<li>" + _renderInline(item) + "</li>";
535
+ }).join("");
536
+ out.push("<ul>" + ulHtml + "</ul>");
537
+ continue;
538
+ }
539
+ if (/^\d+\.\s+/.test(line)) {
540
+ var olItems = [];
541
+ while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
542
+ olItems.push(lines[i].replace(/^\d+\.\s+/, ""));
543
+ i += 1;
544
+ }
545
+ var olHtml = olItems.map(function (item) {
546
+ return "<li>" + _renderInline(item) + "</li>";
547
+ }).join("");
548
+ out.push("<ol>" + olHtml + "</ol>");
549
+ continue;
550
+ }
551
+ var paraLines = [line];
552
+ i += 1;
553
+ while (
554
+ i < lines.length &&
555
+ lines[i].trim() !== "" &&
556
+ !/^#{1,6}\s+/.test(lines[i]) &&
557
+ !/^[-*]\s+/.test(lines[i]) &&
558
+ !/^\d+\.\s+/.test(lines[i]) &&
559
+ !/^>\s?/.test(lines[i]) &&
560
+ !/^-{3,}\s*$/.test(lines[i])
561
+ ) {
562
+ paraLines.push(lines[i]);
563
+ i += 1;
564
+ }
565
+ out.push("<p>" + _renderInline(paraLines.join(" ")) + "</p>");
566
+ }
567
+ return out.join("\n");
568
+ }
569
+
570
+ // ---- factory -----------------------------------------------------------
571
+
572
+ function create(opts) {
573
+ opts = opts || {};
574
+ var query = opts.query;
575
+ if (!query) {
576
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
577
+ }
578
+ // `customers` is reserved for future cross-primitive composition
579
+ // (e.g. byline -> customer-of-record lookups). The factory accepts
580
+ // and ignores it today so existing call-sites that pass the
581
+ // operator-wide customers handle don't fail when the wiring lands.
582
+ var _customers = opts.customers || null;
583
+ if (_customers != null && typeof _customers !== "object") {
584
+ throw new TypeError("blogArticles.create: opts.customers must be a primitive instance or null");
585
+ }
586
+
587
+ if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
588
+ if (process.env.NODE_ENV === "production") {
589
+ throw new Error("blogArticles.create: opts.cursorSecret is required in production");
590
+ }
591
+ opts.cursorSecret = "blog-articles-cursor-secret-dev-only";
592
+ }
593
+ var cursorSecret = opts.cursorSecret;
594
+
595
+ function _decodeCursor(cursor, label) {
596
+ if (cursor == null) return null;
597
+ if (typeof cursor !== "string") {
598
+ throw new TypeError("blogArticles." + label + ": cursor must be an opaque string or null");
599
+ }
600
+ try {
601
+ var state = _b().pagination.decodeCursor(cursor, cursorSecret);
602
+ if (JSON.stringify(state.orderKey) !== JSON.stringify(LIST_ORDER_KEY)) {
603
+ throw new TypeError("blogArticles." + label + ": cursor orderKey mismatch");
604
+ }
605
+ return state.vals;
606
+ } catch (e) {
607
+ if (e instanceof TypeError) throw e;
608
+ throw new TypeError("blogArticles." + label + ": cursor — " + (e && e.message || "malformed"));
609
+ }
610
+ }
611
+
612
+ function _encodeNext(rows, limit) {
613
+ var last = rows[rows.length - 1];
614
+ if (!last || rows.length < limit) return null;
615
+ return _b().pagination.encodeCursor({
616
+ orderKey: LIST_ORDER_KEY,
617
+ vals: [Number(last.published_at), last.slug],
618
+ forward: true,
619
+ }, cursorSecret);
620
+ }
621
+
622
+ // ---- createDraft ----------------------------------------------------
623
+
624
+ async function createDraft(input) {
625
+ if (!input || typeof input !== "object") {
626
+ throw new TypeError("blogArticles.createDraft: input object required");
627
+ }
628
+ var slug = _slug(input.slug);
629
+ var title = _title(input.title);
630
+ var body = _body(input.body);
631
+ var authorId = _authorId(input.author_id);
632
+ var tags = _tagList(input.tags, "tags");
633
+ var featuredProductIds = _featuredProductIds(input.featured_product_ids);
634
+ var heroImageUrl = _heroImageUrl(input.hero_image_url);
635
+ var metaDescription = _metaLine(input.meta_description, "meta_description", MAX_META_DESCRIPTION_LEN);
636
+ var metaKeywords = _metaLine(input.meta_keywords, "meta_keywords", MAX_META_KEYWORDS_LEN);
637
+
638
+ // Refuse a re-insert against an existing slug — the operator
639
+ // should call `update` instead. A silent overwrite would discard
640
+ // the FSM state + the historical published_at stamp.
641
+ var existing = await get(slug);
642
+ if (existing) {
643
+ var err = new Error("blogArticles.createDraft: slug " + JSON.stringify(slug) + " already exists");
644
+ err.code = "BLOG_ARTICLE_EXISTS";
645
+ throw err;
646
+ }
647
+
648
+ var ts = _now();
649
+ await query(
650
+ "INSERT INTO blog_articles (slug, title, body, author_id, tags_json, " +
651
+ "featured_product_ids_json, hero_image_url, meta_description, meta_keywords, " +
652
+ "status, published_at, archived_at, view_count, created_at, updated_at) " +
653
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'draft', NULL, NULL, 0, ?10, ?10)",
654
+ [
655
+ slug,
656
+ title,
657
+ body,
658
+ authorId,
659
+ JSON.stringify(tags),
660
+ JSON.stringify(featuredProductIds),
661
+ heroImageUrl,
662
+ metaDescription,
663
+ metaKeywords,
664
+ ts,
665
+ ],
666
+ );
667
+ return await get(slug);
668
+ }
669
+
670
+ // ---- get / getPublished --------------------------------------------
671
+
672
+ async function get(slug) {
673
+ _slug(slug);
674
+ var r = (await query(
675
+ "SELECT * FROM blog_articles WHERE slug = ?1 LIMIT 1",
676
+ [slug],
677
+ )).rows[0];
678
+ return _hydrateRow(r);
679
+ }
680
+
681
+ async function getPublished(slug) {
682
+ _slug(slug);
683
+ var r = (await query(
684
+ "SELECT * FROM blog_articles WHERE slug = ?1 AND status = 'published' LIMIT 1",
685
+ [slug],
686
+ )).rows[0];
687
+ return _hydrateRow(r);
688
+ }
689
+
690
+ // ---- list helpers ---------------------------------------------------
691
+
692
+ // listPublished: newest-published-first cursor-paginated. Optional
693
+ // tag filter walks the corpus and filters in JS (the operator-
694
+ // facing blog stays small enough that the JS path is the right
695
+ // tradeoff; pushing a JSON-array LIKE into SQLite buys nothing on a
696
+ // corpus of this size and complicates the index plan).
697
+ async function listPublished(listOpts) {
698
+ listOpts = listOpts || {};
699
+ var limit = _limit(listOpts.limit, MAX_LIST_LIMIT, DEFAULT_LIST_LIMIT, "limit");
700
+ var cursorVals = _decodeCursor(listOpts.cursor, "listPublished");
701
+ var tagFilter = listOpts.tag == null ? null : _tagList([listOpts.tag], "tag")[0];
702
+
703
+ var where = ["status = 'published'"];
704
+ var params = [];
705
+ var idx = 1;
706
+
707
+ if (cursorVals) {
708
+ var a = idx;
709
+ var b = idx + 1;
710
+ where.push(
711
+ "(published_at < ?" + a + " OR " +
712
+ "(published_at = ?" + a + " AND slug < ?" + b + "))"
713
+ );
714
+ params.push(cursorVals[0], cursorVals[1]);
715
+ idx += 2;
716
+ }
717
+ // When the tag filter is active, fetch more rows than the limit
718
+ // so the post-filter still yields `limit` rows for the cursor.
719
+ // The corpus is editorial-sized; the over-fetch is bounded by
720
+ // MAX_LIST_LIMIT.
721
+ var fetchLimit = tagFilter ? Math.min(MAX_LIST_LIMIT, limit * 4) : limit;
722
+ params.push(fetchLimit);
723
+ var sql = "SELECT * FROM blog_articles WHERE " + where.join(" AND ") +
724
+ " ORDER BY published_at DESC, slug DESC LIMIT ?" + idx;
725
+ var r = await query(sql, params);
726
+
727
+ var rows = r.rows;
728
+ if (tagFilter) {
729
+ rows = rows.filter(function (row) {
730
+ return _safeJsonArray(row.tags_json).indexOf(tagFilter) !== -1;
731
+ });
732
+ }
733
+ if (rows.length > limit) rows = rows.slice(0, limit);
734
+
735
+ var hydrated = rows.map(_hydrateRow);
736
+ var nextCursor = _encodeNext(rows, limit);
737
+ return { rows: hydrated, next_cursor: nextCursor };
738
+ }
739
+
740
+ async function listDrafts() {
741
+ var rows = (await query(
742
+ "SELECT * FROM blog_articles WHERE status = 'draft' " +
743
+ "ORDER BY created_at ASC, slug ASC",
744
+ [],
745
+ )).rows;
746
+ return rows.map(_hydrateRow);
747
+ }
748
+
749
+ async function listArchived() {
750
+ var rows = (await query(
751
+ "SELECT * FROM blog_articles WHERE status = 'archived' " +
752
+ "ORDER BY archived_at DESC, slug ASC",
753
+ [],
754
+ )).rows;
755
+ return rows.map(_hydrateRow);
756
+ }
757
+
758
+ // ---- update ---------------------------------------------------------
759
+ //
760
+ // Patch any subset of the editable columns. Status / FSM columns are
761
+ // NOT editable here — they move via publish / unpublish / archive /
762
+ // restore. updated_at is stamped on every successful patch.
763
+
764
+ async function update(slug, patch) {
765
+ _slug(slug);
766
+ if (!patch || typeof patch !== "object") {
767
+ throw new TypeError("blogArticles.update: patch object required");
768
+ }
769
+ var keys = Object.keys(patch);
770
+ if (!keys.length) {
771
+ throw new TypeError("blogArticles.update: patch must include at least one column");
772
+ }
773
+
774
+ var current = await get(slug);
775
+ if (!current) {
776
+ var nfErr = new Error("blogArticles.update: slug " + JSON.stringify(slug) + " not found");
777
+ nfErr.code = "BLOG_ARTICLE_NOT_FOUND";
778
+ throw nfErr;
779
+ }
780
+
781
+ var sets = [];
782
+ var params = [];
783
+ var idx = 1;
784
+
785
+ for (var i = 0; i < keys.length; i += 1) {
786
+ var col = keys[i];
787
+ if (ALLOWED_PATCH_COLUMNS.indexOf(col) === -1) {
788
+ throw new TypeError("blogArticles.update: unsupported column " + JSON.stringify(col));
789
+ }
790
+ var v;
791
+ var dbCol = col;
792
+ if (col === "title") { v = _title(patch[col]); }
793
+ else if (col === "body") { v = _body(patch[col]); }
794
+ else if (col === "author_id") { v = _authorId(patch[col]); }
795
+ else if (col === "tags") { v = JSON.stringify(_tagList(patch[col], "tags")); dbCol = "tags_json"; }
796
+ else if (col === "featured_product_ids") {
797
+ v = JSON.stringify(_featuredProductIds(patch[col]));
798
+ dbCol = "featured_product_ids_json";
799
+ }
800
+ else if (col === "hero_image_url") { v = _heroImageUrl(patch[col]); }
801
+ else if (col === "meta_description") { v = _metaLine(patch[col], "meta_description", MAX_META_DESCRIPTION_LEN); }
802
+ else /* meta_keywords */ { v = _metaLine(patch[col], "meta_keywords", MAX_META_KEYWORDS_LEN); }
803
+ sets.push(dbCol + " = ?" + idx);
804
+ params.push(v);
805
+ idx += 1;
806
+ }
807
+
808
+ sets.push("updated_at = ?" + idx);
809
+ params.push(_now());
810
+ idx += 1;
811
+
812
+ params.push(slug);
813
+ var r = await query(
814
+ "UPDATE blog_articles SET " + sets.join(", ") + " WHERE slug = ?" + idx,
815
+ params,
816
+ );
817
+ if (Number(r.rowCount || 0) === 0) {
818
+ var raceErr = new Error("blogArticles.update: slug " + JSON.stringify(slug) + " not found");
819
+ raceErr.code = "BLOG_ARTICLE_NOT_FOUND";
820
+ throw raceErr;
821
+ }
822
+ return await get(slug);
823
+ }
824
+
825
+ // ---- FSM transitions ------------------------------------------------
826
+ //
827
+ // Each transition reads the current row, verifies the from-state is
828
+ // legal for the requested action, and stamps the matching wall-
829
+ // clock column. Illegal transitions throw with a message naming
830
+ // both states so the operator can see what's wedged.
831
+
832
+ function _requireState(current, slug, action, allowedFrom) {
833
+ if (!current) {
834
+ var nfErr = new Error("blogArticles." + action + ": slug " + JSON.stringify(slug) + " not found");
835
+ nfErr.code = "BLOG_ARTICLE_NOT_FOUND";
836
+ throw nfErr;
837
+ }
838
+ if (allowedFrom.indexOf(current.status) === -1) {
839
+ var bsErr = new Error(
840
+ "blogArticles." + action + ": slug " + JSON.stringify(slug) +
841
+ " is in status " + JSON.stringify(current.status) +
842
+ " — " + action + " requires status one of " + JSON.stringify(allowedFrom)
843
+ );
844
+ bsErr.code = "BLOG_ARTICLE_BAD_STATE";
845
+ throw bsErr;
846
+ }
847
+ }
848
+
849
+ async function publish(slug) {
850
+ _slug(slug);
851
+ var current = await get(slug);
852
+ _requireState(current, slug, "publish", ["draft"]);
853
+ var ts = _now();
854
+ // First publish: stamp published_at. Re-publishing from draft
855
+ // after an unpublish reuses the existing published_at so the
856
+ // "this post went live on date X" timestamp stays stable across
857
+ // edit cycles.
858
+ var stampClause = current.published_at == null
859
+ ? "published_at = ?1, "
860
+ : "";
861
+ var sql =
862
+ "UPDATE blog_articles SET status = 'published', " + stampClause +
863
+ "archived_at = NULL, updated_at = ?1 WHERE slug = ?2 AND status = 'draft'";
864
+ var r = await query(sql, [ts, slug]);
865
+ if (Number(r.rowCount || 0) === 0) {
866
+ var raceErr = new Error("blogArticles.publish: slug " + JSON.stringify(slug) + " transition race");
867
+ raceErr.code = "BLOG_ARTICLE_BAD_STATE";
868
+ throw raceErr;
869
+ }
870
+ return await get(slug);
871
+ }
872
+
873
+ async function unpublish(slug) {
874
+ _slug(slug);
875
+ var current = await get(slug);
876
+ _requireState(current, slug, "unpublish", ["published"]);
877
+ var ts = _now();
878
+ var r = await query(
879
+ "UPDATE blog_articles SET status = 'draft', updated_at = ?1 " +
880
+ "WHERE slug = ?2 AND status = 'published'",
881
+ [ts, slug],
882
+ );
883
+ if (Number(r.rowCount || 0) === 0) {
884
+ var raceErr = new Error("blogArticles.unpublish: slug " + JSON.stringify(slug) + " transition race");
885
+ raceErr.code = "BLOG_ARTICLE_BAD_STATE";
886
+ throw raceErr;
887
+ }
888
+ return await get(slug);
889
+ }
890
+
891
+ async function archive(slug) {
892
+ _slug(slug);
893
+ var current = await get(slug);
894
+ _requireState(current, slug, "archive", ["published"]);
895
+ var ts = _now();
896
+ var r = await query(
897
+ "UPDATE blog_articles SET status = 'archived', archived_at = ?1, updated_at = ?1 " +
898
+ "WHERE slug = ?2 AND status = 'published'",
899
+ [ts, slug],
900
+ );
901
+ if (Number(r.rowCount || 0) === 0) {
902
+ var raceErr = new Error("blogArticles.archive: slug " + JSON.stringify(slug) + " transition race");
903
+ raceErr.code = "BLOG_ARTICLE_BAD_STATE";
904
+ throw raceErr;
905
+ }
906
+ return await get(slug);
907
+ }
908
+
909
+ async function restore(slug) {
910
+ _slug(slug);
911
+ var current = await get(slug);
912
+ _requireState(current, slug, "restore", ["archived"]);
913
+ var ts = _now();
914
+ var r = await query(
915
+ "UPDATE blog_articles SET status = 'draft', archived_at = NULL, updated_at = ?1 " +
916
+ "WHERE slug = ?2 AND status = 'archived'",
917
+ [ts, slug],
918
+ );
919
+ if (Number(r.rowCount || 0) === 0) {
920
+ var raceErr = new Error("blogArticles.restore: slug " + JSON.stringify(slug) + " transition race");
921
+ raceErr.code = "BLOG_ARTICLE_BAD_STATE";
922
+ throw raceErr;
923
+ }
924
+ return await get(slug);
925
+ }
926
+
927
+ // ---- renderHtml -----------------------------------------------------
928
+ //
929
+ // Reads the post by slug (any status — the route layer decides
930
+ // whether to gate on `getPublished` first) and returns sanitized
931
+ // HTML for the post body. The renderer never emits raw operator
932
+ // input — every text run is HTML-escaped, every URL is checked
933
+ // against `b.safeUrl.parse`, and any URL that doesn't pass the
934
+ // gate is dropped (the anchor text falls back to inert escaped
935
+ // text). The returned HTML is the *body* of the post — the
936
+ // storefront route wraps it in the site layout + hero image.
937
+
938
+ async function renderHtml(input) {
939
+ if (!input || typeof input !== "object") {
940
+ throw new TypeError("blogArticles.renderHtml: input object required");
941
+ }
942
+ var slug = _slug(input.slug);
943
+ var post = await get(slug);
944
+ if (!post) {
945
+ throw new TypeError("blogArticles.renderHtml: slug " + JSON.stringify(slug) + " not found");
946
+ }
947
+ return _renderMarkdown(post.body);
948
+ }
949
+
950
+ // ---- relatedArticles ------------------------------------------------
951
+ //
952
+ // Top-N published posts ranked by tag-overlap with the requested
953
+ // slug. The requested slug itself is excluded; archived and
954
+ // unpublished posts are excluded. Ties on overlap break on
955
+ // published_at DESC (newer wins), then slug DESC (deterministic).
956
+ //
957
+ // The implementation reads every candidate row in JS rather than
958
+ // pushing the ranker into SQL — the editorial corpus stays small
959
+ // enough that the JS path is the right tradeoff and keeps the
960
+ // primitive in the zero-runtime-deps envelope.
961
+
962
+ async function relatedArticles(input) {
963
+ if (!input || typeof input !== "object") {
964
+ throw new TypeError("blogArticles.relatedArticles: input object required");
965
+ }
966
+ var slug = _slug(input.slug);
967
+ var limit = _limit(input.limit, MAX_RELATED_LIMIT, DEFAULT_RELATED_LIMIT, "limit");
968
+
969
+ var anchor = await get(slug);
970
+ if (!anchor) return [];
971
+ var anchorTags = anchor.tags || [];
972
+ if (!anchorTags.length) return [];
973
+
974
+ var r = await query(
975
+ "SELECT * FROM blog_articles WHERE status = 'published' AND slug <> ?1",
976
+ [slug],
977
+ );
978
+
979
+ var ranked = [];
980
+ for (var i = 0; i < r.rows.length; i += 1) {
981
+ var row = r.rows[i];
982
+ var candidateTags = _safeJsonArray(row.tags_json);
983
+ var overlap = 0;
984
+ for (var t = 0; t < anchorTags.length; t += 1) {
985
+ if (candidateTags.indexOf(anchorTags[t]) !== -1) overlap += 1;
986
+ }
987
+ if (overlap > 0) {
988
+ ranked.push({
989
+ slug: row.slug,
990
+ title: row.title,
991
+ author_id: row.author_id,
992
+ tags: candidateTags,
993
+ published_at: row.published_at == null ? null : Number(row.published_at),
994
+ overlap: overlap,
995
+ });
996
+ }
997
+ }
998
+
999
+ ranked.sort(function (a, b) {
1000
+ if (b.overlap !== a.overlap) return b.overlap - a.overlap;
1001
+ var ap = a.published_at == null ? 0 : a.published_at;
1002
+ var bp = b.published_at == null ? 0 : b.published_at;
1003
+ if (bp !== ap) return bp - ap;
1004
+ if (a.slug < b.slug) return 1;
1005
+ if (a.slug > b.slug) return -1;
1006
+ return 0;
1007
+ });
1008
+
1009
+ return ranked.slice(0, limit);
1010
+ }
1011
+
1012
+ // ---- byAuthor -------------------------------------------------------
1013
+ //
1014
+ // Every post attributed to an author. Defaults to published only;
1015
+ // pass `status: "all"` for every state, or one of the FSM states to
1016
+ // filter narrowly. Ordered newest-published-first (with created_at
1017
+ // as the fallback for draft / archived rows that haven't been
1018
+ // published).
1019
+
1020
+ async function byAuthor(input) {
1021
+ if (!input || typeof input !== "object") {
1022
+ throw new TypeError("blogArticles.byAuthor: input object required");
1023
+ }
1024
+ var authorId = _authorId(input.author_id);
1025
+ var limit = _limit(input.limit, MAX_BY_AUTHOR_LIMIT, DEFAULT_BY_AUTHOR_LIMIT, "limit");
1026
+ var status = input.status == null ? "published" : input.status;
1027
+ if (status !== "all" && ALLOWED_STATUSES.indexOf(status) === -1) {
1028
+ throw new TypeError("blogArticles.byAuthor: status must be one of "
1029
+ + JSON.stringify(ALLOWED_STATUSES.concat(["all"])));
1030
+ }
1031
+
1032
+ var sql;
1033
+ var params;
1034
+ if (status === "all") {
1035
+ sql = "SELECT * FROM blog_articles WHERE author_id = ?1 " +
1036
+ "ORDER BY COALESCE(published_at, created_at) DESC, slug DESC LIMIT ?2";
1037
+ params = [authorId, limit];
1038
+ } else {
1039
+ sql = "SELECT * FROM blog_articles WHERE author_id = ?1 AND status = ?2 " +
1040
+ "ORDER BY COALESCE(published_at, created_at) DESC, slug DESC LIMIT ?3";
1041
+ params = [authorId, status, limit];
1042
+ }
1043
+ var r = await query(sql, params);
1044
+ return r.rows.map(_hydrateRow);
1045
+ }
1046
+
1047
+ // ---- recordView -----------------------------------------------------
1048
+ //
1049
+ // Append-only view log. session_id is hashed at the door; the raw
1050
+ // value never reaches storage. Bumps the slug-wide view_count.
1051
+ // Refuses if the slug doesn't exist (a view against a non-existent
1052
+ // post is an operator wiring bug, not a runtime no-op).
1053
+
1054
+ async function recordView(input) {
1055
+ if (!input || typeof input !== "object") {
1056
+ throw new TypeError("blogArticles.recordView: input object required");
1057
+ }
1058
+ var slug = _slug(input.slug);
1059
+ var current = await get(slug);
1060
+ if (!current) {
1061
+ var nfErr = new Error("blogArticles.recordView: slug " + JSON.stringify(slug) + " not found");
1062
+ nfErr.code = "BLOG_ARTICLE_NOT_FOUND";
1063
+ throw nfErr;
1064
+ }
1065
+ var sessionHash = null;
1066
+ if (input.session_id != null) {
1067
+ sessionHash = _b().crypto.namespaceHash(VIEW_NAMESPACE, _sessionIdRaw(input.session_id));
1068
+ }
1069
+ var ts = _now();
1070
+ await query(
1071
+ "INSERT INTO blog_article_views (id, slug, session_id_hash, occurred_at) " +
1072
+ "VALUES (?1, ?2, ?3, ?4)",
1073
+ [_b().uuid.v7(), slug, sessionHash, ts],
1074
+ );
1075
+ await query(
1076
+ "UPDATE blog_articles SET view_count = view_count + 1 WHERE slug = ?1",
1077
+ [slug],
1078
+ );
1079
+ return { slug: slug, occurred_at: ts };
1080
+ }
1081
+
1082
+ // ---- popularArticles ------------------------------------------------
1083
+ //
1084
+ // Top-N most-viewed posts over a closed time window. Views are
1085
+ // counted from blog_article_views.occurred_at against [from, to].
1086
+ // Archived + unpublished articles are filtered (a popular post that
1087
+ // got unpublished mid-window vanishes from the rail — the operator
1088
+ // un-published it for a reason). Sorted by view count DESC, then
1089
+ // slug DESC.
1090
+
1091
+ async function popularArticles(input) {
1092
+ if (!input || typeof input !== "object") {
1093
+ throw new TypeError("blogArticles.popularArticles: input object required");
1094
+ }
1095
+ var from = input.from;
1096
+ var to = input.to;
1097
+ _timestampRange(from, to, "popularArticles");
1098
+ var limit = _limit(input.limit, MAX_POPULAR_LIMIT, DEFAULT_POPULAR_LIMIT, "limit");
1099
+
1100
+ var sql =
1101
+ "SELECT v.slug AS slug, COUNT(*) AS views " +
1102
+ "FROM blog_article_views v " +
1103
+ "JOIN blog_articles a ON a.slug = v.slug " +
1104
+ "WHERE v.occurred_at >= ?1 AND v.occurred_at <= ?2 " +
1105
+ " AND a.status = 'published' " +
1106
+ "GROUP BY v.slug " +
1107
+ "ORDER BY views DESC, v.slug DESC " +
1108
+ "LIMIT ?3";
1109
+ var r = await query(sql, [from, to, limit]);
1110
+ var out = [];
1111
+ for (var i = 0; i < r.rows.length; i += 1) {
1112
+ var row = r.rows[i];
1113
+ out.push({ slug: row.slug, views: Number(row.views) });
1114
+ }
1115
+ return out;
1116
+ }
1117
+
1118
+ return {
1119
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
1120
+ MAX_TITLE_LEN: MAX_TITLE_LEN,
1121
+ MAX_BODY_LEN: MAX_BODY_LEN,
1122
+ MAX_AUTHOR_ID_LEN: MAX_AUTHOR_ID_LEN,
1123
+ MAX_TAG_LEN: MAX_TAG_LEN,
1124
+ MAX_TAG_COUNT: MAX_TAG_COUNT,
1125
+ MAX_FEATURED_PRODUCT_LEN: MAX_FEATURED_PRODUCT_LEN,
1126
+ MAX_FEATURED_PRODUCT_COUNT: MAX_FEATURED_PRODUCT_COUNT,
1127
+ MAX_HERO_IMAGE_URL_LEN: MAX_HERO_IMAGE_URL_LEN,
1128
+ MAX_META_DESCRIPTION_LEN: MAX_META_DESCRIPTION_LEN,
1129
+ MAX_META_KEYWORDS_LEN: MAX_META_KEYWORDS_LEN,
1130
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
1131
+ MAX_RELATED_LIMIT: MAX_RELATED_LIMIT,
1132
+ MAX_POPULAR_LIMIT: MAX_POPULAR_LIMIT,
1133
+ MAX_BY_AUTHOR_LIMIT: MAX_BY_AUTHOR_LIMIT,
1134
+ ALLOWED_STATUSES: ALLOWED_STATUSES,
1135
+
1136
+ createDraft: createDraft,
1137
+ publish: publish,
1138
+ unpublish: unpublish,
1139
+ archive: archive,
1140
+ restore: restore,
1141
+ update: update,
1142
+ get: get,
1143
+ getPublished: getPublished,
1144
+ listPublished: listPublished,
1145
+ listDrafts: listDrafts,
1146
+ listArchived: listArchived,
1147
+ renderHtml: renderHtml,
1148
+ relatedArticles: relatedArticles,
1149
+ byAuthor: byAuthor,
1150
+ recordView: recordView,
1151
+ popularArticles: popularArticles,
1152
+ };
1153
+ }
1154
+
1155
+ module.exports = {
1156
+ create: create,
1157
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
1158
+ MAX_TITLE_LEN: MAX_TITLE_LEN,
1159
+ MAX_BODY_LEN: MAX_BODY_LEN,
1160
+ MAX_AUTHOR_ID_LEN: MAX_AUTHOR_ID_LEN,
1161
+ MAX_TAG_LEN: MAX_TAG_LEN,
1162
+ MAX_TAG_COUNT: MAX_TAG_COUNT,
1163
+ MAX_FEATURED_PRODUCT_LEN: MAX_FEATURED_PRODUCT_LEN,
1164
+ MAX_FEATURED_PRODUCT_COUNT: MAX_FEATURED_PRODUCT_COUNT,
1165
+ MAX_HERO_IMAGE_URL_LEN: MAX_HERO_IMAGE_URL_LEN,
1166
+ MAX_META_DESCRIPTION_LEN: MAX_META_DESCRIPTION_LEN,
1167
+ MAX_META_KEYWORDS_LEN: MAX_META_KEYWORDS_LEN,
1168
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
1169
+ MAX_RELATED_LIMIT: MAX_RELATED_LIMIT,
1170
+ MAX_POPULAR_LIMIT: MAX_POPULAR_LIMIT,
1171
+ MAX_BY_AUTHOR_LIMIT: MAX_BY_AUTHOR_LIMIT,
1172
+ ALLOWED_STATUSES: ALLOWED_STATUSES,
1173
+ };