@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.
@@ -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 `&lt;`.
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
+ };