@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.
- package/CHANGELOG.md +4 -0
- package/lib/api-keys.js +789 -0
- package/lib/barcodes.js +671 -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/coupon-stacking.js +717 -0
- package/lib/customer-import.js +590 -0
- package/lib/customer-portal.js +359 -0
- package/lib/discount-analytics.js +548 -0
- package/lib/dunning.js +700 -0
- package/lib/experiments.js +697 -0
- package/lib/gift-card-ledger.js +483 -0
- package/lib/index.js +25 -0
- package/lib/inventory-snapshots.js +691 -0
- package/lib/operator-audit-log.js +621 -0
- package/lib/print-receipts.js +675 -0
- package/lib/product-import.js +1034 -0
- package/lib/search-facets.js +825 -0
- package/lib/sms-dispatcher.js +945 -0
- package/lib/storefront-forms.js +884 -0
- package/lib/storefront-pages.js +701 -0
- package/lib/subscription-billing.js +644 -0
- package/lib/tax-rates.js +559 -0
- package/lib/tenants.js +665 -0
- package/lib/translations.js +553 -0
- package/lib/webhook-subscriptions.js +565 -0
- package/package.json +1 -1
|
@@ -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
|
+
// `<` 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
|
+
};
|