@blamejs/blamejs-shop 0.0.59 → 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,701 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.storefrontPages
4
+ * @title Storefront CMS pages — operator-authored Markdown with a publish FSM
5
+ *
6
+ * @intro
7
+ * Operator-authored static content surfaced at fixed slugs on the
8
+ * storefront — About, Shipping, Returns Policy, Privacy, Terms,
9
+ * the long-tail of pages that don't belong in the catalog but
10
+ * every shop needs.
11
+ *
12
+ * Each page carries:
13
+ * - a stable URL-friendly `slug`,
14
+ * - a `title` shown in the page header and the `<title>` tag,
15
+ * - a `body` authored in a Markdown subset (paragraphs,
16
+ * headings, lists, links, inline code, emphasis, hard breaks),
17
+ * - optional `meta_description` + `meta_keywords` for SEO,
18
+ * - a `layout` token picked from a closed enum
19
+ * (default / wide / landing / legal),
20
+ * - a `status` FSM (draft → published → archived) with a
21
+ * restore path back to draft,
22
+ * - a monotonic `version` counter incremented on every update.
23
+ *
24
+ * The publish FSM:
25
+ *
26
+ * draft ──publish──▶ published
27
+ * published ──unpublish─▶ draft
28
+ * published ──archive───▶ archived
29
+ * archived ──restore───▶ draft
30
+ *
31
+ * Every other transition is refused with an error that names the
32
+ * current state and the attempted action so an operator running
33
+ * a publish sweep can see exactly which page is wedged.
34
+ *
35
+ * Composes:
36
+ * - `b.template.escapeHtml` — every text run from the operator-
37
+ * authored body lands as HTML-escaped output in `renderHtml`.
38
+ * The Markdown renderer never emits raw operator input.
39
+ * - `b.safeUrl.parse` — inline `[text](url)` link URLs pass the
40
+ * https-only allowlist (or `/`-rooted absolute paths). A link
41
+ * carrying `javascript:` / `data:` / protocol-relative `//host`
42
+ * is dropped from the rendered HTML and the anchor text falls
43
+ * back to inert escaped text.
44
+ *
45
+ * Surface:
46
+ * - `create({ query })` — factory. `query` is optional; absent it,
47
+ * the primitive talks to `b.externalDb.query` directly.
48
+ * - `defineDraft({ slug, title, body, meta_description?,
49
+ * meta_keywords?, layout })` — insert a row in
50
+ * `draft` status, version 1.
51
+ * - `publish(slug)` / `unpublish(slug)` / `archive(slug)` /
52
+ * `restore(slug)` — FSM transitions.
53
+ * - `update(slug, patch)` — patch any of the editable columns
54
+ * (title / body / meta_description / meta_keywords / layout),
55
+ * increments `version`.
56
+ * - `get(slug)` — any status. `getPublished(slug)` — published
57
+ * only (returns null for any other state).
58
+ * - `listPublished()` / `listDrafts()` / `listArchived()` —
59
+ * enumerate by FSM state. `listPublished` returns newest-
60
+ * published-first; the other two return creation-order.
61
+ * - `renderHtml({ slug })` — async; reads the page by slug and
62
+ * returns sanitized HTML from the Markdown body. The slug
63
+ * must exist or the call throws (`renderHtml` is config-tier:
64
+ * a missing slug is an operator bug). The render output never
65
+ * contains a live `<script>`, `<img onerror=...>`, or
66
+ * `javascript:` URL — every text run is escaped, every URL is
67
+ * URL-validated before it lands in the `href`.
68
+ *
69
+ * Storage:
70
+ * - `storefront_pages` (migration `0059_storefront_pages.sql`).
71
+ *
72
+ * @primitive storefrontPages
73
+ * @related b.template.escapeHtml, b.safeUrl.parse
74
+ */
75
+
76
+ var MAX_SLUG_LEN = 80;
77
+ var MAX_TITLE_LEN = 200;
78
+ var MAX_BODY_LEN = 200000;
79
+ var MAX_META_DESCRIPTION_LEN = 320;
80
+ var MAX_META_KEYWORDS_LEN = 320;
81
+
82
+ var ALLOWED_LAYOUTS = Object.freeze([
83
+ "default",
84
+ "wide",
85
+ "landing",
86
+ "legal",
87
+ ]);
88
+
89
+ var ALLOWED_STATUSES = Object.freeze([
90
+ "draft",
91
+ "published",
92
+ "archived",
93
+ ]);
94
+
95
+ var ALLOWED_PATCH_COLUMNS = Object.freeze([
96
+ "title",
97
+ "body",
98
+ "meta_description",
99
+ "meta_keywords",
100
+ "layout",
101
+ ]);
102
+
103
+ // Slug shape mirrors the rest of the storefront primitives — alnum
104
+ // leading character, alnum + dot + hyphen + underscore tail, capped
105
+ // length. The slug reaches operator logs + the public storefront URL
106
+ // `/pages/<slug>`, so the shape stays narrow.
107
+ var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
108
+
109
+ // Refuse C0 control bytes + DEL in operator-authored strings. The
110
+ // body permits LF (Markdown is line-oriented); single-line fields
111
+ // refuse LF / CR so they can't smuggle a second line into a page
112
+ // header / meta tag.
113
+ var CONTROL_BYTE_LINE_RE = /[\x00-\x1f\x7f]/;
114
+ var CONTROL_BYTE_BLOCK_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
115
+
116
+ // Zero-width / direction-override family — mirrors the promo-banners
117
+ // + gift-options catalogues. Spelled with \u-escapes so ESLint's
118
+ // no-irregular-whitespace stays happy.
119
+ var ZERO_WIDTH_RE = new RegExp(
120
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
121
+ );
122
+
123
+ var bShop;
124
+ function _b() {
125
+ if (!bShop) bShop = require("./index");
126
+ return bShop.framework;
127
+ }
128
+
129
+ // ---- validators ---------------------------------------------------------
130
+
131
+ function _slug(s) {
132
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
133
+ throw new TypeError("storefrontPages: slug must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (≤ " + MAX_SLUG_LEN + " chars)");
134
+ }
135
+ return s;
136
+ }
137
+
138
+ function _title(s) {
139
+ if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
140
+ throw new TypeError("storefrontPages: title must be a non-empty string ≤ " + MAX_TITLE_LEN + " chars");
141
+ }
142
+ if (CONTROL_BYTE_LINE_RE.test(s)) {
143
+ throw new TypeError("storefrontPages: title contains control bytes (incl. CR/LF)");
144
+ }
145
+ if (ZERO_WIDTH_RE.test(s)) {
146
+ throw new TypeError("storefrontPages: title contains zero-width / direction-override characters");
147
+ }
148
+ return s;
149
+ }
150
+
151
+ function _body(s) {
152
+ if (typeof s !== "string" || !s.length || s.length > MAX_BODY_LEN) {
153
+ throw new TypeError("storefrontPages: body must be a non-empty string ≤ " + MAX_BODY_LEN + " chars");
154
+ }
155
+ if (CONTROL_BYTE_BLOCK_RE.test(s)) {
156
+ throw new TypeError("storefrontPages: body contains control bytes");
157
+ }
158
+ if (ZERO_WIDTH_RE.test(s)) {
159
+ throw new TypeError("storefrontPages: body contains zero-width / direction-override characters");
160
+ }
161
+ return s;
162
+ }
163
+
164
+ function _metaLine(s, label, maxLen) {
165
+ if (s == null) return null;
166
+ if (typeof s !== "string" || s.length > maxLen) {
167
+ throw new TypeError("storefrontPages: " + label + " must be a string ≤ " + maxLen + " chars");
168
+ }
169
+ if (CONTROL_BYTE_LINE_RE.test(s)) {
170
+ throw new TypeError("storefrontPages: " + label + " contains control bytes (incl. CR/LF)");
171
+ }
172
+ if (ZERO_WIDTH_RE.test(s)) {
173
+ throw new TypeError("storefrontPages: " + label + " contains zero-width / direction-override characters");
174
+ }
175
+ return s;
176
+ }
177
+
178
+ function _layout(s) {
179
+ if (s == null) return "default";
180
+ if (typeof s !== "string" || ALLOWED_LAYOUTS.indexOf(s) === -1) {
181
+ throw new TypeError("storefrontPages: layout must be one of " + JSON.stringify(ALLOWED_LAYOUTS));
182
+ }
183
+ return s;
184
+ }
185
+
186
+ function _now() { return Date.now(); }
187
+
188
+ // ---- row hydration ------------------------------------------------------
189
+
190
+ function _hydrateRow(r) {
191
+ if (!r) return null;
192
+ return {
193
+ slug: r.slug,
194
+ version: Number(r.version),
195
+ title: r.title,
196
+ body: r.body,
197
+ meta_description: r.meta_description,
198
+ meta_keywords: r.meta_keywords,
199
+ layout: r.layout,
200
+ status: r.status,
201
+ published_at: r.published_at == null ? null : Number(r.published_at),
202
+ archived_at: r.archived_at == null ? null : Number(r.archived_at),
203
+ created_at: Number(r.created_at),
204
+ updated_at: Number(r.updated_at),
205
+ };
206
+ }
207
+
208
+ // ---- Markdown to HTML ---------------------------------------------------
209
+ //
210
+ // A minimal in-process Markdown subset, hand-written to keep the
211
+ // primitive in the zero-runtime-deps envelope. The subset is exactly
212
+ // what an operator authoring About / Shipping / Privacy / Terms
213
+ // needs:
214
+ //
215
+ // # H1 ## H2 ### H3 #### H4 ##### H5 ###### H6
216
+ // Paragraphs (blank-line separated)
217
+ // - bulleted list / * bulleted list
218
+ // 1. numbered list
219
+ // `inline code`
220
+ // **bold** *italic* _italic_
221
+ // [link text](https://example.com/) / [text](/internal/path)
222
+ // `>` blockquote
223
+ // `---` horizontal rule
224
+ //
225
+ // Every text run is HTML-escaped via `b.template.escapeHtml`. Every
226
+ // link URL is validated via `b.safeUrl.parse` (https:// allowlist) OR
227
+ // accepted as a `/`-rooted absolute path. Any URL that fails the
228
+ // gate is dropped from the rendered HTML — the anchor text falls
229
+ // back to inert escaped text so a hostile body can't smuggle a
230
+ // `javascript:` URL into the storefront. The renderer does NOT
231
+ // support raw HTML pass-through; any `<` in the body lands as
232
+ // `&lt;` in the output. That's the whole defense against XSS:
233
+ // every operator-input byte is HTML-escaped before it reaches the
234
+ // `<main>` of the page.
235
+
236
+ function _esc(s) {
237
+ return _b().template.escapeHtml(s);
238
+ }
239
+
240
+ // Try the link URL through safeUrl (https-only) OR accept a `/`-rooted
241
+ // absolute path. Returns the URL if it passes, `null` if it doesn't.
242
+ // Protocol-relative `//host/...` is refused so a CDN mis-config can't
243
+ // downgrade an inline link.
244
+ function _safeLinkUrl(url) {
245
+ if (typeof url !== "string" || !url.length || url.length > 2048) return null;
246
+ if (CONTROL_BYTE_LINE_RE.test(url) || ZERO_WIDTH_RE.test(url)) return null;
247
+ if (url.charCodeAt(0) === 47 /* "/" */) {
248
+ if (url.length > 1 && url.charCodeAt(1) === 47) return null;
249
+ if (url.indexOf("..") !== -1) return null;
250
+ return url;
251
+ }
252
+ try {
253
+ _b().safeUrl.parse(url, { allowedProtocols: ["https:"] });
254
+ } catch (_e) {
255
+ return null;
256
+ }
257
+ return url;
258
+ }
259
+
260
+ // Inline renderer — walks a single line of Markdown and emits HTML.
261
+ // Order matters: inline code (`...`) consumes its content first so a
262
+ // pair of backticks around `**bold**` renders as `<code>**bold**</code>`,
263
+ // not `<code><strong>bold</strong></code>`. Then links, then bold,
264
+ // then italic. Every text run between matches is HTML-escaped.
265
+ function _renderInline(line) {
266
+ var out = "";
267
+ var i = 0;
268
+ while (i < line.length) {
269
+ var ch = line.charAt(i);
270
+ // Inline code: `text` (no nesting, no escapes inside).
271
+ if (ch === "`") {
272
+ var end = line.indexOf("`", i + 1);
273
+ if (end !== -1) {
274
+ out += "<code>" + _esc(line.slice(i + 1, end)) + "</code>";
275
+ i = end + 1;
276
+ continue;
277
+ }
278
+ }
279
+ // Link: [text](url)
280
+ if (ch === "[") {
281
+ var closeBracket = line.indexOf("]", i + 1);
282
+ if (closeBracket !== -1 && line.charAt(closeBracket + 1) === "(") {
283
+ var closeParen = line.indexOf(")", closeBracket + 2);
284
+ if (closeParen !== -1) {
285
+ var text = line.slice(i + 1, closeBracket);
286
+ var url = line.slice(closeBracket + 2, closeParen);
287
+ var safe = _safeLinkUrl(url);
288
+ if (safe) {
289
+ // Render text through the inline pipeline too so a
290
+ // `[**bold link**](https://...)` renders as expected. The
291
+ // recursion is bounded by the slice — `text` is strictly
292
+ // shorter than the input.
293
+ out += '<a href="' + _esc(safe) + '">' + _renderInline(text) + "</a>";
294
+ } else {
295
+ // Drop the URL; render the anchor text as inert escaped
296
+ // text. No `<a>` ever wraps an unsafe URL.
297
+ out += _renderInline(text);
298
+ }
299
+ i = closeParen + 1;
300
+ continue;
301
+ }
302
+ }
303
+ }
304
+ // Bold: **text**
305
+ if (ch === "*" && line.charAt(i + 1) === "*") {
306
+ var endBold = line.indexOf("**", i + 2);
307
+ if (endBold !== -1) {
308
+ out += "<strong>" + _renderInline(line.slice(i + 2, endBold)) + "</strong>";
309
+ i = endBold + 2;
310
+ continue;
311
+ }
312
+ }
313
+ // Italic: *text* or _text_
314
+ if (ch === "*" || ch === "_") {
315
+ var endItalic = line.indexOf(ch, i + 1);
316
+ if (endItalic !== -1 && endItalic !== i + 1) {
317
+ out += "<em>" + _renderInline(line.slice(i + 1, endItalic)) + "</em>";
318
+ i = endItalic + 1;
319
+ continue;
320
+ }
321
+ }
322
+ // Default: HTML-escape the character.
323
+ out += _esc(ch);
324
+ i += 1;
325
+ }
326
+ return out;
327
+ }
328
+
329
+ // Block renderer — splits the body on blank lines, classifies each
330
+ // block by its first character(s), and emits the matching HTML.
331
+ // Lists span contiguous lines starting with the same marker; the
332
+ // renderer accumulates them into a single <ul> / <ol>.
333
+ function _renderMarkdown(body) {
334
+ var normalized = String(body).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
335
+ var lines = normalized.split("\n");
336
+ var out = [];
337
+ var i = 0;
338
+ while (i < lines.length) {
339
+ var line = lines[i];
340
+ // Blank line — skip.
341
+ if (line.trim() === "") { i += 1; continue; }
342
+ // Horizontal rule.
343
+ if (/^-{3,}\s*$/.test(line)) {
344
+ out.push("<hr />");
345
+ i += 1;
346
+ continue;
347
+ }
348
+ // Heading: # ... ###### (1-6 hashes + space).
349
+ var hMatch = /^(#{1,6})\s+(.*)$/.exec(line);
350
+ if (hMatch) {
351
+ var level = hMatch[1].length;
352
+ out.push("<h" + level + ">" + _renderInline(hMatch[2].trim()) + "</h" + level + ">");
353
+ i += 1;
354
+ continue;
355
+ }
356
+ // Blockquote — one or more consecutive `> ` lines.
357
+ if (/^>\s?/.test(line)) {
358
+ var quoteLines = [];
359
+ while (i < lines.length && /^>\s?/.test(lines[i])) {
360
+ quoteLines.push(lines[i].replace(/^>\s?/, ""));
361
+ i += 1;
362
+ }
363
+ out.push("<blockquote><p>" + _renderInline(quoteLines.join(" ")) + "</p></blockquote>");
364
+ continue;
365
+ }
366
+ // Unordered list — one or more consecutive `- ` / `* ` lines.
367
+ if (/^[-*]\s+/.test(line)) {
368
+ var ulItems = [];
369
+ while (i < lines.length && /^[-*]\s+/.test(lines[i])) {
370
+ ulItems.push(lines[i].replace(/^[-*]\s+/, ""));
371
+ i += 1;
372
+ }
373
+ var ulHtml = ulItems.map(function (item) {
374
+ return "<li>" + _renderInline(item) + "</li>";
375
+ }).join("");
376
+ out.push("<ul>" + ulHtml + "</ul>");
377
+ continue;
378
+ }
379
+ // Ordered list — one or more consecutive `<digits>. ` lines.
380
+ if (/^\d+\.\s+/.test(line)) {
381
+ var olItems = [];
382
+ while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
383
+ olItems.push(lines[i].replace(/^\d+\.\s+/, ""));
384
+ i += 1;
385
+ }
386
+ var olHtml = olItems.map(function (item) {
387
+ return "<li>" + _renderInline(item) + "</li>";
388
+ }).join("");
389
+ out.push("<ol>" + olHtml + "</ol>");
390
+ continue;
391
+ }
392
+ // Paragraph — accumulate consecutive non-blank, non-block lines.
393
+ var paraLines = [line];
394
+ i += 1;
395
+ while (
396
+ i < lines.length &&
397
+ lines[i].trim() !== "" &&
398
+ !/^#{1,6}\s+/.test(lines[i]) &&
399
+ !/^[-*]\s+/.test(lines[i]) &&
400
+ !/^\d+\.\s+/.test(lines[i]) &&
401
+ !/^>\s?/.test(lines[i]) &&
402
+ !/^-{3,}\s*$/.test(lines[i])
403
+ ) {
404
+ paraLines.push(lines[i]);
405
+ i += 1;
406
+ }
407
+ out.push("<p>" + _renderInline(paraLines.join(" ")) + "</p>");
408
+ }
409
+ return out.join("\n");
410
+ }
411
+
412
+ // ---- factory ------------------------------------------------------------
413
+
414
+ function create(opts) {
415
+ opts = opts || {};
416
+ var query = opts.query;
417
+ if (!query) {
418
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
419
+ }
420
+
421
+ // -- defineDraft -------------------------------------------------------
422
+
423
+ async function defineDraft(input) {
424
+ if (!input || typeof input !== "object") {
425
+ throw new TypeError("storefrontPages.defineDraft: input object required");
426
+ }
427
+ var slug = _slug(input.slug);
428
+ var title = _title(input.title);
429
+ var body = _body(input.body);
430
+ var metaDescription = _metaLine(input.meta_description, "meta_description", MAX_META_DESCRIPTION_LEN);
431
+ var metaKeywords = _metaLine(input.meta_keywords, "meta_keywords", MAX_META_KEYWORDS_LEN);
432
+ var layout = _layout(input.layout);
433
+
434
+ var ts = _now();
435
+ await query(
436
+ "INSERT INTO storefront_pages (slug, version, title, body, meta_description, meta_keywords, " +
437
+ "layout, status, published_at, archived_at, created_at, updated_at) " +
438
+ "VALUES (?1, 1, ?2, ?3, ?4, ?5, ?6, 'draft', NULL, NULL, ?7, ?7)",
439
+ [slug, title, body, metaDescription, metaKeywords, layout, ts],
440
+ );
441
+ return await get(slug);
442
+ }
443
+
444
+ // -- get / getPublished -----------------------------------------------
445
+
446
+ async function get(slug) {
447
+ _slug(slug);
448
+ var r = (await query(
449
+ "SELECT * FROM storefront_pages WHERE slug = ?1 LIMIT 1",
450
+ [slug],
451
+ )).rows[0];
452
+ return _hydrateRow(r);
453
+ }
454
+
455
+ async function getPublished(slug) {
456
+ _slug(slug);
457
+ var r = (await query(
458
+ "SELECT * FROM storefront_pages WHERE slug = ?1 AND status = 'published' LIMIT 1",
459
+ [slug],
460
+ )).rows[0];
461
+ return _hydrateRow(r);
462
+ }
463
+
464
+ // -- list helpers -----------------------------------------------------
465
+
466
+ async function listPublished() {
467
+ var rows = (await query(
468
+ "SELECT * FROM storefront_pages WHERE status = 'published' " +
469
+ "ORDER BY published_at DESC, slug ASC",
470
+ [],
471
+ )).rows;
472
+ return rows.map(_hydrateRow);
473
+ }
474
+
475
+ async function listDrafts() {
476
+ var rows = (await query(
477
+ "SELECT * FROM storefront_pages WHERE status = 'draft' " +
478
+ "ORDER BY created_at ASC, slug ASC",
479
+ [],
480
+ )).rows;
481
+ return rows.map(_hydrateRow);
482
+ }
483
+
484
+ async function listArchived() {
485
+ var rows = (await query(
486
+ "SELECT * FROM storefront_pages WHERE status = 'archived' " +
487
+ "ORDER BY archived_at DESC, slug ASC",
488
+ [],
489
+ )).rows;
490
+ return rows.map(_hydrateRow);
491
+ }
492
+
493
+ // -- update -----------------------------------------------------------
494
+ //
495
+ // Patch any subset of the editable columns (title / body /
496
+ // meta_description / meta_keywords / layout). Every call increments
497
+ // `version` so an operator can verify "the change I just made
498
+ // landed" against the returned row. Status / FSM columns are NOT
499
+ // editable here — they move via publish / unpublish / archive /
500
+ // restore.
501
+
502
+ async function update(slug, patch) {
503
+ _slug(slug);
504
+ if (!patch || typeof patch !== "object") {
505
+ throw new TypeError("storefrontPages.update: patch object required");
506
+ }
507
+ var keys = Object.keys(patch);
508
+ if (!keys.length) {
509
+ throw new TypeError("storefrontPages.update: patch must include at least one column");
510
+ }
511
+
512
+ var current = await get(slug);
513
+ if (!current) {
514
+ throw new TypeError("storefrontPages.update: slug " + JSON.stringify(slug) + " not found");
515
+ }
516
+
517
+ var sets = [];
518
+ var params = [];
519
+ var idx = 1;
520
+
521
+ for (var i = 0; i < keys.length; i += 1) {
522
+ var col = keys[i];
523
+ if (ALLOWED_PATCH_COLUMNS.indexOf(col) === -1) {
524
+ throw new TypeError("storefrontPages.update: unsupported column " + JSON.stringify(col));
525
+ }
526
+ var v;
527
+ if (col === "title") { v = _title(patch[col]); }
528
+ else if (col === "body") { v = _body(patch[col]); }
529
+ else if (col === "meta_description") { v = _metaLine(patch[col], "meta_description", MAX_META_DESCRIPTION_LEN); }
530
+ else if (col === "meta_keywords") { v = _metaLine(patch[col], "meta_keywords", MAX_META_KEYWORDS_LEN); }
531
+ else /* layout */ { v = _layout(patch[col]); }
532
+ sets.push(col + " = ?" + idx);
533
+ params.push(v);
534
+ idx += 1;
535
+ }
536
+
537
+ // Bump version + stamp updated_at.
538
+ sets.push("version = version + 1");
539
+ sets.push("updated_at = ?" + idx);
540
+ params.push(_now());
541
+ idx += 1;
542
+
543
+ params.push(slug);
544
+ var r = await query(
545
+ "UPDATE storefront_pages SET " + sets.join(", ") + " WHERE slug = ?" + idx,
546
+ params,
547
+ );
548
+ if (Number(r.rowCount || 0) === 0) {
549
+ throw new TypeError("storefrontPages.update: slug " + JSON.stringify(slug) + " not found");
550
+ }
551
+ return await get(slug);
552
+ }
553
+
554
+ // -- FSM transitions --------------------------------------------------
555
+ //
556
+ // Each transition reads the current row, verifies the from-state is
557
+ // legal for the requested action, and stamps the matching wall-
558
+ // clock column. Illegal transitions throw with a message that names
559
+ // both states so the operator can see what's wedged.
560
+
561
+ function _requireState(current, slug, action, allowedFrom) {
562
+ if (!current) {
563
+ throw new TypeError("storefrontPages." + action + ": slug " + JSON.stringify(slug) + " not found");
564
+ }
565
+ if (allowedFrom.indexOf(current.status) === -1) {
566
+ throw new TypeError(
567
+ "storefrontPages." + action + ": slug " + JSON.stringify(slug) +
568
+ " is in status " + JSON.stringify(current.status) +
569
+ " — " + action + " requires status one of " + JSON.stringify(allowedFrom)
570
+ );
571
+ }
572
+ }
573
+
574
+ async function publish(slug) {
575
+ _slug(slug);
576
+ var current = await get(slug);
577
+ _requireState(current, slug, "publish", ["draft"]);
578
+ var ts = _now();
579
+ // First publish: stamp published_at. Re-publishing from draft
580
+ // after an unpublish reuses the existing published_at so the
581
+ // historical "first went live" timestamp stays stable. The
582
+ // operator-facing model is "this slug went live on date X"; an
583
+ // edit cycle in the middle doesn't reset that.
584
+ var stampClause = current.published_at == null
585
+ ? "published_at = ?1, "
586
+ : "";
587
+ var sql =
588
+ "UPDATE storefront_pages SET status = 'published', " + stampClause +
589
+ "archived_at = NULL, updated_at = ?1 WHERE slug = ?2 AND status = 'draft'";
590
+ var r = await query(sql, [ts, slug]);
591
+ if (Number(r.rowCount || 0) === 0) {
592
+ throw new TypeError("storefrontPages.publish: slug " + JSON.stringify(slug) + " transition race");
593
+ }
594
+ return await get(slug);
595
+ }
596
+
597
+ async function unpublish(slug) {
598
+ _slug(slug);
599
+ var current = await get(slug);
600
+ _requireState(current, slug, "unpublish", ["published"]);
601
+ var ts = _now();
602
+ var r = await query(
603
+ "UPDATE storefront_pages SET status = 'draft', updated_at = ?1 " +
604
+ "WHERE slug = ?2 AND status = 'published'",
605
+ [ts, slug],
606
+ );
607
+ if (Number(r.rowCount || 0) === 0) {
608
+ throw new TypeError("storefrontPages.unpublish: slug " + JSON.stringify(slug) + " transition race");
609
+ }
610
+ return await get(slug);
611
+ }
612
+
613
+ async function archive(slug) {
614
+ _slug(slug);
615
+ var current = await get(slug);
616
+ _requireState(current, slug, "archive", ["published"]);
617
+ var ts = _now();
618
+ var r = await query(
619
+ "UPDATE storefront_pages SET status = 'archived', archived_at = ?1, updated_at = ?1 " +
620
+ "WHERE slug = ?2 AND status = 'published'",
621
+ [ts, slug],
622
+ );
623
+ if (Number(r.rowCount || 0) === 0) {
624
+ throw new TypeError("storefrontPages.archive: slug " + JSON.stringify(slug) + " transition race");
625
+ }
626
+ return await get(slug);
627
+ }
628
+
629
+ async function restore(slug) {
630
+ _slug(slug);
631
+ var current = await get(slug);
632
+ _requireState(current, slug, "restore", ["archived"]);
633
+ var ts = _now();
634
+ var r = await query(
635
+ "UPDATE storefront_pages SET status = 'draft', archived_at = NULL, updated_at = ?1 " +
636
+ "WHERE slug = ?2 AND status = 'archived'",
637
+ [ts, slug],
638
+ );
639
+ if (Number(r.rowCount || 0) === 0) {
640
+ throw new TypeError("storefrontPages.restore: slug " + JSON.stringify(slug) + " transition race");
641
+ }
642
+ return await get(slug);
643
+ }
644
+
645
+ // -- renderHtml -------------------------------------------------------
646
+ //
647
+ // Reads the page by slug (any status — the route layer decides
648
+ // whether to gate on `getPublished` first) and returns sanitized
649
+ // HTML for the page body. The renderer never emits raw operator
650
+ // input — every text run is HTML-escaped, every URL is checked
651
+ // against `b.safeUrl.parse`, and any URL that doesn't pass the
652
+ // gate is dropped (the anchor text falls back to inert escaped
653
+ // text). The returned HTML is the *body* of the page — the
654
+ // storefront route wraps it in the site layout.
655
+
656
+ async function renderHtml(input) {
657
+ if (!input || typeof input !== "object") {
658
+ throw new TypeError("storefrontPages.renderHtml: input object required");
659
+ }
660
+ var slug = _slug(input.slug);
661
+ var page = await get(slug);
662
+ if (!page) {
663
+ throw new TypeError("storefrontPages.renderHtml: slug " + JSON.stringify(slug) + " not found");
664
+ }
665
+ return _renderMarkdown(page.body);
666
+ }
667
+
668
+ return {
669
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
670
+ MAX_TITLE_LEN: MAX_TITLE_LEN,
671
+ MAX_BODY_LEN: MAX_BODY_LEN,
672
+ MAX_META_DESCRIPTION_LEN: MAX_META_DESCRIPTION_LEN,
673
+ MAX_META_KEYWORDS_LEN: MAX_META_KEYWORDS_LEN,
674
+ ALLOWED_LAYOUTS: ALLOWED_LAYOUTS,
675
+ ALLOWED_STATUSES: ALLOWED_STATUSES,
676
+
677
+ defineDraft: defineDraft,
678
+ publish: publish,
679
+ unpublish: unpublish,
680
+ archive: archive,
681
+ restore: restore,
682
+ update: update,
683
+ get: get,
684
+ getPublished: getPublished,
685
+ listPublished: listPublished,
686
+ listDrafts: listDrafts,
687
+ listArchived: listArchived,
688
+ renderHtml: renderHtml,
689
+ };
690
+ }
691
+
692
+ module.exports = {
693
+ create: create,
694
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
695
+ MAX_TITLE_LEN: MAX_TITLE_LEN,
696
+ MAX_BODY_LEN: MAX_BODY_LEN,
697
+ MAX_META_DESCRIPTION_LEN: MAX_META_DESCRIPTION_LEN,
698
+ MAX_META_KEYWORDS_LEN: MAX_META_KEYWORDS_LEN,
699
+ ALLOWED_LAYOUTS: ALLOWED_LAYOUTS,
700
+ ALLOWED_STATUSES: ALLOWED_STATUSES,
701
+ };