@blamejs/blamejs-shop 0.0.60 → 0.0.61
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 +2 -0
- package/lib/carrier-rates.js +683 -0
- package/lib/cart-bulk-ops.js +711 -0
- package/lib/cms-blocks.js +651 -0
- package/lib/code-minter.js +535 -0
- package/lib/customer-import.js +590 -0
- package/lib/discount-analytics.js +548 -0
- package/lib/dunning.js +700 -0
- package/lib/gift-card-ledger.js +483 -0
- package/lib/index.js +11 -0
- package/lib/operator-audit-log.js +621 -0
- package/lib/search-facets.js +825 -0
- package/lib/sms-dispatcher.js +945 -0
- package/lib/storefront-forms.js +884 -0
- package/package.json +1 -1
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.cmsBlocks
|
|
4
|
+
* @title CMS blocks — operator-editable content slots in storefront templates
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* A closed catalogue of operator-editable content slots embedded
|
|
8
|
+
* in the server-rendered storefront templates. Where storefront
|
|
9
|
+
* pages own whole URLs ("about", "shipping"), cms blocks own
|
|
10
|
+
* *fragments* that the templates reach for at render time:
|
|
11
|
+
*
|
|
12
|
+
* header_announcement — the strip rendered above the site nav.
|
|
13
|
+
* hero — the homepage hero copy block.
|
|
14
|
+
* category_hero — the per-category-page hero copy.
|
|
15
|
+
* pdp_bottom — the slot under the product-detail copy.
|
|
16
|
+
* footer_column — operator-authored footer column copy.
|
|
17
|
+
* checkout_success — the "thanks for ordering" body.
|
|
18
|
+
* inline — generic fall-through for template-local
|
|
19
|
+
* slots an operator wires up ad-hoc.
|
|
20
|
+
*
|
|
21
|
+
* Each block carries:
|
|
22
|
+
* - a stable operator-chosen `key` (PK),
|
|
23
|
+
* - a `default_body` authored in a Markdown subset (the baseline
|
|
24
|
+
* rendered when no localization matches the requested locale),
|
|
25
|
+
* - a `layout` token from the closed enum above (so the template
|
|
26
|
+
* picks the matching wrapper),
|
|
27
|
+
* - an `archived_at` flag (archived blocks render as ""),
|
|
28
|
+
* - a list of `cms_block_localizations` rows, each carrying
|
|
29
|
+
* a `locale`, `body`, monotonic `version`, and an optional
|
|
30
|
+
* (publish_at, expire_at) window.
|
|
31
|
+
*
|
|
32
|
+
* Version semantics:
|
|
33
|
+
* Every `setLocalized` call appends a new row with
|
|
34
|
+
* `version = MAX(version) + 1` for the (key, locale) pair. The
|
|
35
|
+
* renderer walks (key, locale) versions newest-first and picks
|
|
36
|
+
* the first row whose publish window covers `now`. Older
|
|
37
|
+
* versions stay queryable via `versionsForBlock` so an operator
|
|
38
|
+
* can roll back by re-authoring the previous body.
|
|
39
|
+
*
|
|
40
|
+
* Locale fallback:
|
|
41
|
+
* `getRendered({ key, locale, now? })` walks BCP-47 subtags
|
|
42
|
+
* right-to-left — "fr-CA" tries "fr-CA", then "fr". If no
|
|
43
|
+
* localization matches (or the matching localization's publish
|
|
44
|
+
* window doesn't cover `now`), the renderer falls back to
|
|
45
|
+
* `default_body`. Archived blocks short-circuit to "".
|
|
46
|
+
*
|
|
47
|
+
* Composes:
|
|
48
|
+
* - `b.template.escapeHtml` — every text run from the operator-
|
|
49
|
+
* authored body lands HTML-escaped in the rendered output. The
|
|
50
|
+
* in-process Markdown renderer never emits raw operator input.
|
|
51
|
+
* - `b.safeUrl.parse` — link URLs pass the https-only allowlist
|
|
52
|
+
* (or `/`-rooted absolute paths). javascript: / data: /
|
|
53
|
+
* protocol-relative `//host` URLs are dropped at render time;
|
|
54
|
+
* the anchor text falls back to inert escaped text.
|
|
55
|
+
* - `b.uuid.v7` — localization row ids.
|
|
56
|
+
*
|
|
57
|
+
* Surface:
|
|
58
|
+
* - `create({ query?, translations? })` — factory. `query`
|
|
59
|
+
* defaults to `b.externalDb.query`. `translations` is an
|
|
60
|
+
* optional reference to the `translations` primitive instance
|
|
61
|
+
* so a future caller can compose locale chain helpers; the
|
|
62
|
+
* cmsBlocks primitive computes its own fallback chain locally.
|
|
63
|
+
* - `defineBlock({ key, default_body, layout })` — idempotent.
|
|
64
|
+
* New key inserts a block; existing key updates default_body
|
|
65
|
+
* + layout (and clears archived_at).
|
|
66
|
+
* - `setLocalized({ key, locale, body, publish_at?, expire_at? })`
|
|
67
|
+
* — appends a new version row for (key, locale).
|
|
68
|
+
* - `getRendered({ key, locale, now? })` — async; returns HTML
|
|
69
|
+
* for the block. Locale fallback + publish-window respected;
|
|
70
|
+
* archived blocks render "".
|
|
71
|
+
* - `listBlocks({ include_archived? })` — enumerate every block.
|
|
72
|
+
* - `archiveBlock(key)` — stamps archived_at; renderer returns
|
|
73
|
+
* "" for the block until the operator re-defines it.
|
|
74
|
+
* - `update(key, patch)` — patches default_body / layout only;
|
|
75
|
+
* refuses every other column.
|
|
76
|
+
* - `versionsForBlock({ key, locale })` — version history for a
|
|
77
|
+
* (key, locale) pair, newest first.
|
|
78
|
+
*
|
|
79
|
+
* Storage:
|
|
80
|
+
* - `cms_blocks` (migration `0079_cms_blocks.sql`).
|
|
81
|
+
* - `cms_block_localizations` (same migration).
|
|
82
|
+
*
|
|
83
|
+
* @primitive cmsBlocks
|
|
84
|
+
* @related b.template.escapeHtml, b.safeUrl, b.uuid
|
|
85
|
+
*/
|
|
86
|
+
|
|
87
|
+
var MAX_KEY_LEN = 80;
|
|
88
|
+
var MAX_BODY_LEN = 50000;
|
|
89
|
+
var MAX_LOCALE_LEN = 35; // BCP-47 cap is 35 chars
|
|
90
|
+
|
|
91
|
+
var ALLOWED_LAYOUTS = Object.freeze([
|
|
92
|
+
"header_announcement",
|
|
93
|
+
"hero",
|
|
94
|
+
"category_hero",
|
|
95
|
+
"pdp_bottom",
|
|
96
|
+
"footer_column",
|
|
97
|
+
"checkout_success",
|
|
98
|
+
"inline",
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
var ALLOWED_PATCH_COLUMNS = Object.freeze([
|
|
102
|
+
"default_body",
|
|
103
|
+
"layout",
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
// Key shape mirrors the rest of the storefront primitives — alnum
|
|
107
|
+
// leading character, alnum + dot + hyphen + underscore tail, capped
|
|
108
|
+
// length. The key reaches operator logs + an admin URL so the shape
|
|
109
|
+
// stays narrow.
|
|
110
|
+
var KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
111
|
+
|
|
112
|
+
// BCP-47 locale shape — language (2-3 letters) + optional subtags.
|
|
113
|
+
// Lowercase the input before matching against this regex so the
|
|
114
|
+
// fallback walker sees a stable canonical form.
|
|
115
|
+
var LOCALE_RE = /^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/;
|
|
116
|
+
|
|
117
|
+
// Refuse C0 control bytes + DEL in operator-authored bodies. LF / CR
|
|
118
|
+
// / tab are allowed — operators author multi-line Markdown.
|
|
119
|
+
var CONTROL_BYTE_BLOCK_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
|
|
120
|
+
|
|
121
|
+
// Zero-width / direction-override family. Spelled with \u-escapes
|
|
122
|
+
// so ESLint's no-irregular-whitespace stays happy. Same defence as
|
|
123
|
+
// the rest of the shop primitives.
|
|
124
|
+
var ZERO_WIDTH_RE = new RegExp(
|
|
125
|
+
"[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
var bShop;
|
|
129
|
+
function _b() {
|
|
130
|
+
if (!bShop) bShop = require("./index");
|
|
131
|
+
return bShop.framework;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---- validators ---------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
function _key(s) {
|
|
137
|
+
if (typeof s !== "string" || !KEY_RE.test(s)) {
|
|
138
|
+
throw new TypeError("cmsBlocks: key must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= " + MAX_KEY_LEN + " chars)");
|
|
139
|
+
}
|
|
140
|
+
return s;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function _body(s, label) {
|
|
144
|
+
label = label || "body";
|
|
145
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_BODY_LEN) {
|
|
146
|
+
throw new TypeError("cmsBlocks: " + label + " must be a non-empty string <= " + MAX_BODY_LEN + " chars");
|
|
147
|
+
}
|
|
148
|
+
if (CONTROL_BYTE_BLOCK_RE.test(s)) {
|
|
149
|
+
throw new TypeError("cmsBlocks: " + label + " contains control bytes");
|
|
150
|
+
}
|
|
151
|
+
if (ZERO_WIDTH_RE.test(s)) {
|
|
152
|
+
throw new TypeError("cmsBlocks: " + label + " contains zero-width / direction-override characters");
|
|
153
|
+
}
|
|
154
|
+
return s;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function _layout(s) {
|
|
158
|
+
if (s == null) return "inline";
|
|
159
|
+
if (typeof s !== "string" || ALLOWED_LAYOUTS.indexOf(s) === -1) {
|
|
160
|
+
throw new TypeError("cmsBlocks: layout must be one of " + JSON.stringify(ALLOWED_LAYOUTS));
|
|
161
|
+
}
|
|
162
|
+
return s;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function _locale(s) {
|
|
166
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_LOCALE_LEN) {
|
|
167
|
+
throw new TypeError("cmsBlocks: locale must be a non-empty BCP-47 string <= " + MAX_LOCALE_LEN + " chars");
|
|
168
|
+
}
|
|
169
|
+
var canonical = s.toLowerCase();
|
|
170
|
+
if (!LOCALE_RE.test(canonical)) {
|
|
171
|
+
throw new TypeError("cmsBlocks: locale " + JSON.stringify(s) + " is not a valid BCP-47 shape");
|
|
172
|
+
}
|
|
173
|
+
return canonical;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function _publishWindowField(v, label) {
|
|
177
|
+
if (v == null) return null;
|
|
178
|
+
if (typeof v !== "number" || !isFinite(v) || v < 0 || Math.floor(v) !== v) {
|
|
179
|
+
throw new TypeError("cmsBlocks: " + label + " must be a non-negative integer epoch-ms (or null)");
|
|
180
|
+
}
|
|
181
|
+
return v;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function _now() { return Date.now(); }
|
|
185
|
+
|
|
186
|
+
// Walk a canonical BCP-47 locale right-to-left, dropping trailing
|
|
187
|
+
// subtags. "fr-ca" -> ["fr-ca", "fr"]. The default_body baseline is
|
|
188
|
+
// handled by the caller (this returns only the localization keys to
|
|
189
|
+
// search).
|
|
190
|
+
function _fallbackChain(locale) {
|
|
191
|
+
var chain = [];
|
|
192
|
+
var cur = locale;
|
|
193
|
+
while (cur && cur.length) {
|
|
194
|
+
chain.push(cur);
|
|
195
|
+
var idx = cur.lastIndexOf("-");
|
|
196
|
+
if (idx === -1) break;
|
|
197
|
+
cur = cur.slice(0, idx);
|
|
198
|
+
}
|
|
199
|
+
return chain;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---- row hydration ------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
function _hydrateBlockRow(r) {
|
|
205
|
+
if (!r) return null;
|
|
206
|
+
return {
|
|
207
|
+
key: r.key,
|
|
208
|
+
default_body: r.default_body,
|
|
209
|
+
layout: r.layout,
|
|
210
|
+
archived_at: r.archived_at == null ? null : Number(r.archived_at),
|
|
211
|
+
created_at: Number(r.created_at),
|
|
212
|
+
updated_at: Number(r.updated_at),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function _hydrateLocalizationRow(r) {
|
|
217
|
+
if (!r) return null;
|
|
218
|
+
return {
|
|
219
|
+
id: r.id,
|
|
220
|
+
block_key: r.block_key,
|
|
221
|
+
locale: r.locale,
|
|
222
|
+
body: r.body,
|
|
223
|
+
version: Number(r.version),
|
|
224
|
+
publish_at: r.publish_at == null ? null : Number(r.publish_at),
|
|
225
|
+
expire_at: r.expire_at == null ? null : Number(r.expire_at),
|
|
226
|
+
created_at: Number(r.created_at),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---- Markdown to HTML ---------------------------------------------------
|
|
231
|
+
//
|
|
232
|
+
// A minimal in-process Markdown subset, hand-written to keep the
|
|
233
|
+
// primitive in the zero-runtime-deps envelope. Mirrors the storefront
|
|
234
|
+
// pages renderer: paragraphs, headings, lists, links, inline code,
|
|
235
|
+
// emphasis. Every text run is HTML-escaped via `b.template.escapeHtml`.
|
|
236
|
+
// Every link URL passes through `b.safeUrl.parse` (https-only) OR an
|
|
237
|
+
// allow-list for `/`-rooted absolute paths. Any URL that fails the
|
|
238
|
+
// gate is dropped from the rendered HTML; the anchor text falls back
|
|
239
|
+
// to inert escaped text. Raw HTML in the body is never passed through
|
|
240
|
+
// — any `<` lands as `<`.
|
|
241
|
+
|
|
242
|
+
function _esc(s) {
|
|
243
|
+
return _b().template.escapeHtml(s);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function _safeLinkUrl(url) {
|
|
247
|
+
if (typeof url !== "string" || !url.length || url.length > 2048) return null;
|
|
248
|
+
if (CONTROL_BYTE_BLOCK_RE.test(url) || ZERO_WIDTH_RE.test(url)) return null;
|
|
249
|
+
if (url.charCodeAt(0) === 47 /* "/" */) {
|
|
250
|
+
if (url.length > 1 && url.charCodeAt(1) === 47) return null;
|
|
251
|
+
if (url.indexOf("..") !== -1) return null;
|
|
252
|
+
return url;
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
_b().safeUrl.parse(url, { allowedProtocols: ["https:"] });
|
|
256
|
+
} catch (_e) {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
return url;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function _renderInline(line) {
|
|
263
|
+
var out = "";
|
|
264
|
+
var i = 0;
|
|
265
|
+
while (i < line.length) {
|
|
266
|
+
var ch = line.charAt(i);
|
|
267
|
+
if (ch === "`") {
|
|
268
|
+
var end = line.indexOf("`", i + 1);
|
|
269
|
+
if (end !== -1) {
|
|
270
|
+
out += "<code>" + _esc(line.slice(i + 1, end)) + "</code>";
|
|
271
|
+
i = end + 1;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (ch === "[") {
|
|
276
|
+
var closeBracket = line.indexOf("]", i + 1);
|
|
277
|
+
if (closeBracket !== -1 && line.charAt(closeBracket + 1) === "(") {
|
|
278
|
+
var closeParen = line.indexOf(")", closeBracket + 2);
|
|
279
|
+
if (closeParen !== -1) {
|
|
280
|
+
var text = line.slice(i + 1, closeBracket);
|
|
281
|
+
var url = line.slice(closeBracket + 2, closeParen);
|
|
282
|
+
var safe = _safeLinkUrl(url);
|
|
283
|
+
if (safe) {
|
|
284
|
+
out += '<a href="' + _esc(safe) + '">' + _renderInline(text) + "</a>";
|
|
285
|
+
} else {
|
|
286
|
+
out += _renderInline(text);
|
|
287
|
+
}
|
|
288
|
+
i = closeParen + 1;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (ch === "*" && line.charAt(i + 1) === "*") {
|
|
294
|
+
var endBold = line.indexOf("**", i + 2);
|
|
295
|
+
if (endBold !== -1) {
|
|
296
|
+
out += "<strong>" + _renderInline(line.slice(i + 2, endBold)) + "</strong>";
|
|
297
|
+
i = endBold + 2;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (ch === "*" || ch === "_") {
|
|
302
|
+
var endItalic = line.indexOf(ch, i + 1);
|
|
303
|
+
if (endItalic !== -1 && endItalic !== i + 1) {
|
|
304
|
+
out += "<em>" + _renderInline(line.slice(i + 1, endItalic)) + "</em>";
|
|
305
|
+
i = endItalic + 1;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
out += _esc(ch);
|
|
310
|
+
i += 1;
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function _renderMarkdown(body) {
|
|
316
|
+
var normalized = String(body).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
317
|
+
var lines = normalized.split("\n");
|
|
318
|
+
var out = [];
|
|
319
|
+
var i = 0;
|
|
320
|
+
while (i < lines.length) {
|
|
321
|
+
var line = lines[i];
|
|
322
|
+
if (line.trim() === "") { i += 1; continue; }
|
|
323
|
+
if (/^-{3,}\s*$/.test(line)) {
|
|
324
|
+
out.push("<hr />");
|
|
325
|
+
i += 1;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
var hMatch = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
329
|
+
if (hMatch) {
|
|
330
|
+
var level = hMatch[1].length;
|
|
331
|
+
out.push("<h" + level + ">" + _renderInline(hMatch[2].trim()) + "</h" + level + ">");
|
|
332
|
+
i += 1;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (/^>\s?/.test(line)) {
|
|
336
|
+
var quoteLines = [];
|
|
337
|
+
while (i < lines.length && /^>\s?/.test(lines[i])) {
|
|
338
|
+
quoteLines.push(lines[i].replace(/^>\s?/, ""));
|
|
339
|
+
i += 1;
|
|
340
|
+
}
|
|
341
|
+
out.push("<blockquote><p>" + _renderInline(quoteLines.join(" ")) + "</p></blockquote>");
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (/^[-*]\s+/.test(line)) {
|
|
345
|
+
var ulItems = [];
|
|
346
|
+
while (i < lines.length && /^[-*]\s+/.test(lines[i])) {
|
|
347
|
+
ulItems.push(lines[i].replace(/^[-*]\s+/, ""));
|
|
348
|
+
i += 1;
|
|
349
|
+
}
|
|
350
|
+
var ulHtml = ulItems.map(function (item) {
|
|
351
|
+
return "<li>" + _renderInline(item) + "</li>";
|
|
352
|
+
}).join("");
|
|
353
|
+
out.push("<ul>" + ulHtml + "</ul>");
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (/^\d+\.\s+/.test(line)) {
|
|
357
|
+
var olItems = [];
|
|
358
|
+
while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
|
|
359
|
+
olItems.push(lines[i].replace(/^\d+\.\s+/, ""));
|
|
360
|
+
i += 1;
|
|
361
|
+
}
|
|
362
|
+
var olHtml = olItems.map(function (item) {
|
|
363
|
+
return "<li>" + _renderInline(item) + "</li>";
|
|
364
|
+
}).join("");
|
|
365
|
+
out.push("<ol>" + olHtml + "</ol>");
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
var paraLines = [line];
|
|
369
|
+
i += 1;
|
|
370
|
+
while (
|
|
371
|
+
i < lines.length &&
|
|
372
|
+
lines[i].trim() !== "" &&
|
|
373
|
+
!/^#{1,6}\s+/.test(lines[i]) &&
|
|
374
|
+
!/^[-*]\s+/.test(lines[i]) &&
|
|
375
|
+
!/^\d+\.\s+/.test(lines[i]) &&
|
|
376
|
+
!/^>\s?/.test(lines[i]) &&
|
|
377
|
+
!/^-{3,}\s*$/.test(lines[i])
|
|
378
|
+
) {
|
|
379
|
+
paraLines.push(lines[i]);
|
|
380
|
+
i += 1;
|
|
381
|
+
}
|
|
382
|
+
out.push("<p>" + _renderInline(paraLines.join(" ")) + "</p>");
|
|
383
|
+
}
|
|
384
|
+
return out.join("\n");
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ---- factory ------------------------------------------------------------
|
|
388
|
+
|
|
389
|
+
function create(opts) {
|
|
390
|
+
opts = opts || {};
|
|
391
|
+
var query = opts.query;
|
|
392
|
+
if (!query) {
|
|
393
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
394
|
+
}
|
|
395
|
+
// Optional translations primitive reference. The cmsBlocks
|
|
396
|
+
// primitive computes its own locale fallback chain locally; the
|
|
397
|
+
// handle is kept on the factory so a future caller can pipe
|
|
398
|
+
// resource-level translations through the same render path
|
|
399
|
+
// without re-plumbing the dependency.
|
|
400
|
+
var translationsRef = opts.translations || null;
|
|
401
|
+
void translationsRef;
|
|
402
|
+
|
|
403
|
+
// -- defineBlock ------------------------------------------------------
|
|
404
|
+
//
|
|
405
|
+
// Idempotent. Inserts a new (key) row OR updates the default_body /
|
|
406
|
+
// layout of an existing row (and clears archived_at so a previously-
|
|
407
|
+
// retired key comes back alive). Localizations are not touched —
|
|
408
|
+
// the operator can re-author a block's default copy without
|
|
409
|
+
// disturbing the version history of any locale.
|
|
410
|
+
|
|
411
|
+
async function defineBlock(input) {
|
|
412
|
+
if (!input || typeof input !== "object") {
|
|
413
|
+
throw new TypeError("cmsBlocks.defineBlock: input object required");
|
|
414
|
+
}
|
|
415
|
+
var key = _key(input.key);
|
|
416
|
+
var defaultBody = _body(input.default_body, "default_body");
|
|
417
|
+
var layout = _layout(input.layout);
|
|
418
|
+
var ts = _now();
|
|
419
|
+
|
|
420
|
+
var existing = await getBlock(key);
|
|
421
|
+
if (existing) {
|
|
422
|
+
await query(
|
|
423
|
+
"UPDATE cms_blocks SET default_body = ?1, layout = ?2, archived_at = NULL, updated_at = ?3 WHERE key = ?4",
|
|
424
|
+
[defaultBody, layout, ts, key],
|
|
425
|
+
);
|
|
426
|
+
} else {
|
|
427
|
+
await query(
|
|
428
|
+
"INSERT INTO cms_blocks (key, default_body, layout, archived_at, created_at, updated_at) " +
|
|
429
|
+
"VALUES (?1, ?2, ?3, NULL, ?4, ?4)",
|
|
430
|
+
[key, defaultBody, layout, ts],
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
return await getBlock(key);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// -- getBlock ---------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
async function getBlock(key) {
|
|
439
|
+
_key(key);
|
|
440
|
+
var r = (await query(
|
|
441
|
+
"SELECT * FROM cms_blocks WHERE key = ?1 LIMIT 1",
|
|
442
|
+
[key],
|
|
443
|
+
)).rows[0];
|
|
444
|
+
return _hydrateBlockRow(r);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// -- listBlocks -------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
async function listBlocks(input) {
|
|
450
|
+
input = input || {};
|
|
451
|
+
var includeArchived = input.include_archived === true;
|
|
452
|
+
var sql = includeArchived
|
|
453
|
+
? "SELECT * FROM cms_blocks ORDER BY key ASC"
|
|
454
|
+
: "SELECT * FROM cms_blocks WHERE archived_at IS NULL ORDER BY key ASC";
|
|
455
|
+
var rows = (await query(sql, [])).rows;
|
|
456
|
+
return rows.map(_hydrateBlockRow);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// -- archiveBlock -----------------------------------------------------
|
|
460
|
+
|
|
461
|
+
async function archiveBlock(key) {
|
|
462
|
+
_key(key);
|
|
463
|
+
var existing = await getBlock(key);
|
|
464
|
+
if (!existing) {
|
|
465
|
+
throw new TypeError("cmsBlocks.archiveBlock: key " + JSON.stringify(key) + " not found");
|
|
466
|
+
}
|
|
467
|
+
var ts = _now();
|
|
468
|
+
await query(
|
|
469
|
+
"UPDATE cms_blocks SET archived_at = ?1, updated_at = ?1 WHERE key = ?2",
|
|
470
|
+
[ts, key],
|
|
471
|
+
);
|
|
472
|
+
return await getBlock(key);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// -- update -----------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
async function update(key, patch) {
|
|
478
|
+
_key(key);
|
|
479
|
+
if (!patch || typeof patch !== "object") {
|
|
480
|
+
throw new TypeError("cmsBlocks.update: patch object required");
|
|
481
|
+
}
|
|
482
|
+
var keys = Object.keys(patch);
|
|
483
|
+
if (!keys.length) {
|
|
484
|
+
throw new TypeError("cmsBlocks.update: patch must include at least one column");
|
|
485
|
+
}
|
|
486
|
+
var existing = await getBlock(key);
|
|
487
|
+
if (!existing) {
|
|
488
|
+
throw new TypeError("cmsBlocks.update: key " + JSON.stringify(key) + " not found");
|
|
489
|
+
}
|
|
490
|
+
var sets = [];
|
|
491
|
+
var params = [];
|
|
492
|
+
var idx = 1;
|
|
493
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
494
|
+
var col = keys[i];
|
|
495
|
+
if (ALLOWED_PATCH_COLUMNS.indexOf(col) === -1) {
|
|
496
|
+
throw new TypeError("cmsBlocks.update: unsupported column " + JSON.stringify(col));
|
|
497
|
+
}
|
|
498
|
+
var v;
|
|
499
|
+
if (col === "default_body") { v = _body(patch[col], "default_body"); }
|
|
500
|
+
else /* layout */ { v = _layout(patch[col]); }
|
|
501
|
+
sets.push(col + " = ?" + idx);
|
|
502
|
+
params.push(v);
|
|
503
|
+
idx += 1;
|
|
504
|
+
}
|
|
505
|
+
sets.push("updated_at = ?" + idx);
|
|
506
|
+
params.push(_now());
|
|
507
|
+
idx += 1;
|
|
508
|
+
params.push(key);
|
|
509
|
+
var r = await query(
|
|
510
|
+
"UPDATE cms_blocks SET " + sets.join(", ") + " WHERE key = ?" + idx,
|
|
511
|
+
params,
|
|
512
|
+
);
|
|
513
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
514
|
+
throw new TypeError("cmsBlocks.update: key " + JSON.stringify(key) + " not found");
|
|
515
|
+
}
|
|
516
|
+
return await getBlock(key);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// -- setLocalized -----------------------------------------------------
|
|
520
|
+
//
|
|
521
|
+
// Append a new version row for (key, locale). The version number
|
|
522
|
+
// is `MAX(version) + 1` across the existing rows for the pair (1
|
|
523
|
+
// for the first localization). Publish window columns are
|
|
524
|
+
// optional; null means "no bound."
|
|
525
|
+
|
|
526
|
+
async function setLocalized(input) {
|
|
527
|
+
if (!input || typeof input !== "object") {
|
|
528
|
+
throw new TypeError("cmsBlocks.setLocalized: input object required");
|
|
529
|
+
}
|
|
530
|
+
var key = _key(input.key);
|
|
531
|
+
var locale = _locale(input.locale);
|
|
532
|
+
var body = _body(input.body, "body");
|
|
533
|
+
var publishAt = _publishWindowField(input.publish_at, "publish_at");
|
|
534
|
+
var expireAt = _publishWindowField(input.expire_at, "expire_at");
|
|
535
|
+
if (publishAt != null && expireAt != null && expireAt <= publishAt) {
|
|
536
|
+
throw new TypeError("cmsBlocks.setLocalized: expire_at must be strictly greater than publish_at");
|
|
537
|
+
}
|
|
538
|
+
var existing = await getBlock(key);
|
|
539
|
+
if (!existing) {
|
|
540
|
+
throw new TypeError("cmsBlocks.setLocalized: key " + JSON.stringify(key) + " not found");
|
|
541
|
+
}
|
|
542
|
+
if (existing.archived_at != null) {
|
|
543
|
+
throw new TypeError("cmsBlocks.setLocalized: key " + JSON.stringify(key) + " is archived; restore via defineBlock first");
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
var maxRow = (await query(
|
|
547
|
+
"SELECT MAX(version) AS max_v FROM cms_block_localizations WHERE block_key = ?1 AND locale = ?2",
|
|
548
|
+
[key, locale],
|
|
549
|
+
)).rows[0];
|
|
550
|
+
var nextVersion = maxRow && maxRow.max_v != null ? Number(maxRow.max_v) + 1 : 1;
|
|
551
|
+
|
|
552
|
+
var id = _b().uuid.v7();
|
|
553
|
+
var ts = _now();
|
|
554
|
+
await query(
|
|
555
|
+
"INSERT INTO cms_block_localizations (id, block_key, locale, body, version, publish_at, expire_at, created_at) " +
|
|
556
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
|
557
|
+
[id, key, locale, body, nextVersion, publishAt, expireAt, ts],
|
|
558
|
+
);
|
|
559
|
+
var row = (await query(
|
|
560
|
+
"SELECT * FROM cms_block_localizations WHERE id = ?1 LIMIT 1",
|
|
561
|
+
[id],
|
|
562
|
+
)).rows[0];
|
|
563
|
+
return _hydrateLocalizationRow(row);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// -- versionsForBlock -------------------------------------------------
|
|
567
|
+
|
|
568
|
+
async function versionsForBlock(input) {
|
|
569
|
+
if (!input || typeof input !== "object") {
|
|
570
|
+
throw new TypeError("cmsBlocks.versionsForBlock: input object required");
|
|
571
|
+
}
|
|
572
|
+
var key = _key(input.key);
|
|
573
|
+
var locale = _locale(input.locale);
|
|
574
|
+
var rows = (await query(
|
|
575
|
+
"SELECT * FROM cms_block_localizations WHERE block_key = ?1 AND locale = ?2 " +
|
|
576
|
+
"ORDER BY version DESC",
|
|
577
|
+
[key, locale],
|
|
578
|
+
)).rows;
|
|
579
|
+
return rows.map(_hydrateLocalizationRow);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// -- getRendered ------------------------------------------------------
|
|
583
|
+
//
|
|
584
|
+
// Resolve the active body for (key, locale, now), render it to
|
|
585
|
+
// HTML, and return the string. Resolution order:
|
|
586
|
+
//
|
|
587
|
+
// 1. Archived block -> "" (short-circuit).
|
|
588
|
+
// 2. Walk locale fallback chain (right-to-left subtag strip);
|
|
589
|
+
// for each candidate locale, read the highest-version row
|
|
590
|
+
// whose publish window covers `now`. First match wins.
|
|
591
|
+
// 3. No localization matches -> render default_body.
|
|
592
|
+
//
|
|
593
|
+
// The publish window check: a row is active iff
|
|
594
|
+
// (publish_at IS NULL OR publish_at <= now) AND
|
|
595
|
+
// (expire_at IS NULL OR expire_at > now).
|
|
596
|
+
|
|
597
|
+
async function getRendered(input) {
|
|
598
|
+
if (!input || typeof input !== "object") {
|
|
599
|
+
throw new TypeError("cmsBlocks.getRendered: input object required");
|
|
600
|
+
}
|
|
601
|
+
var key = _key(input.key);
|
|
602
|
+
var locale = _locale(input.locale);
|
|
603
|
+
var now = input.now == null ? _now() : _publishWindowField(input.now, "now");
|
|
604
|
+
|
|
605
|
+
var block = await getBlock(key);
|
|
606
|
+
if (!block) {
|
|
607
|
+
throw new TypeError("cmsBlocks.getRendered: key " + JSON.stringify(key) + " not found");
|
|
608
|
+
}
|
|
609
|
+
if (block.archived_at != null) return "";
|
|
610
|
+
|
|
611
|
+
var chain = _fallbackChain(locale);
|
|
612
|
+
for (var i = 0; i < chain.length; i += 1) {
|
|
613
|
+
var candidate = chain[i];
|
|
614
|
+
var rows = (await query(
|
|
615
|
+
"SELECT * FROM cms_block_localizations WHERE block_key = ?1 AND locale = ?2 " +
|
|
616
|
+
"AND (publish_at IS NULL OR publish_at <= ?3) " +
|
|
617
|
+
"AND (expire_at IS NULL OR expire_at > ?3) " +
|
|
618
|
+
"ORDER BY version DESC LIMIT 1",
|
|
619
|
+
[key, candidate, now],
|
|
620
|
+
)).rows;
|
|
621
|
+
if (rows.length) {
|
|
622
|
+
return _renderMarkdown(rows[0].body);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return _renderMarkdown(block.default_body);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return {
|
|
629
|
+
MAX_KEY_LEN: MAX_KEY_LEN,
|
|
630
|
+
MAX_BODY_LEN: MAX_BODY_LEN,
|
|
631
|
+
MAX_LOCALE_LEN: MAX_LOCALE_LEN,
|
|
632
|
+
ALLOWED_LAYOUTS: ALLOWED_LAYOUTS,
|
|
633
|
+
|
|
634
|
+
defineBlock: defineBlock,
|
|
635
|
+
setLocalized: setLocalized,
|
|
636
|
+
getRendered: getRendered,
|
|
637
|
+
listBlocks: listBlocks,
|
|
638
|
+
archiveBlock: archiveBlock,
|
|
639
|
+
update: update,
|
|
640
|
+
versionsForBlock: versionsForBlock,
|
|
641
|
+
getBlock: getBlock,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
module.exports = {
|
|
646
|
+
create: create,
|
|
647
|
+
MAX_KEY_LEN: MAX_KEY_LEN,
|
|
648
|
+
MAX_BODY_LEN: MAX_BODY_LEN,
|
|
649
|
+
MAX_LOCALE_LEN: MAX_LOCALE_LEN,
|
|
650
|
+
ALLOWED_LAYOUTS: ALLOWED_LAYOUTS,
|
|
651
|
+
};
|