@blamejs/blamejs-shop 0.0.72 → 0.0.76
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 +8 -0
- package/lib/announcement-bar.js +753 -0
- package/lib/banner-ab-tests.js +806 -0
- package/lib/bin-locations.js +791 -0
- package/lib/blog-articles.js +1173 -0
- package/lib/carrier-accounts.js +805 -0
- package/lib/cart-recovery.js +1133 -0
- package/lib/category-navigation.js +934 -0
- package/lib/consent-ledger.js +539 -0
- package/lib/customer-impersonation.js +743 -0
- package/lib/customer-merge.js +879 -0
- package/lib/demand-forecast.js +1121 -0
- package/lib/dispute-resolution.js +886 -0
- package/lib/email-ab-tests.js +918 -0
- package/lib/email-engagement-score.js +649 -0
- package/lib/event-log.js +713 -0
- package/lib/fulfillment-sla.js +791 -0
- package/lib/index.js +41 -0
- package/lib/inventory-audits.js +852 -0
- package/lib/line-gift-wrap.js +430 -0
- package/lib/marketing-budget.js +792 -0
- package/lib/operator-activity-feed.js +977 -0
- package/lib/operator-approvals.js +942 -0
- package/lib/operator-help-center.js +1020 -0
- package/lib/operator-inbox.js +889 -0
- package/lib/operator-sessions.js +701 -0
- package/lib/order-exchanges.js +602 -0
- package/lib/product-compare.js +804 -0
- package/lib/pwa-manifest.js +1005 -0
- package/lib/referral-leaderboard.js +612 -0
- package/lib/sales-tax-filings.js +807 -0
- package/lib/search-ranking.js +859 -0
- package/lib/shipping-insurance.js +757 -0
- package/lib/shrinkage-report.js +1182 -0
- package/lib/sidebar-widgets.js +952 -0
- package/lib/smart-restocking.js +1048 -0
- package/lib/stock-receipts.js +834 -0
- package/lib/subscription-analytics.js +1032 -0
- package/lib/suggestion-box.js +921 -0
- package/lib/tax-remittance.js +625 -0
- package/lib/vendor-invoices.js +1021 -0
- package/lib/winback-campaigns.js +1350 -0
- package/lib/wishlist-digest.js +1133 -0
- 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
|
+
// `<`. 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
|
+
};
|